zen-gitsync 2.17.20 → 2.17.22

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.
Files changed (139) hide show
  1. package/README.md +22 -14
  2. package/package.json +9 -1
  3. package/scripts/release.js +214 -82
  4. package/src/ui/public/assets/AgentEngineSelector-DR3r5eQE.css +1 -0
  5. package/src/ui/public/assets/AgentEngineSelector-Dtvp5u3j.js +1 -0
  6. package/src/ui/public/assets/AgentView-BzfjNz7k.js +1 -0
  7. package/src/ui/public/assets/AgentView-DnN-vCMi.css +1 -0
  8. package/src/ui/public/assets/{AppVersionBadge-DDYQqoy7.css → AppVersionBadge--5If494j.css} +1 -1
  9. package/src/ui/public/assets/AppVersionBadge-DAAnAFNp.js +2 -0
  10. package/src/ui/public/assets/{BranchSelector-F4ibldMN.css → BranchSelector-BTCMYwG0.css} +1 -1
  11. package/src/ui/public/assets/BranchSelector-C20zVkp_.js +1 -0
  12. package/src/ui/public/assets/CommitForm-C-cL8_vz.css +1 -0
  13. package/src/ui/public/assets/CommitForm-COc-NwA8.js +6 -0
  14. package/src/ui/public/assets/CommonDialog-B7r-EaIr.js +1 -0
  15. package/src/ui/public/assets/CommonDialog-Bzi5ISqT.css +1 -0
  16. package/src/ui/public/assets/EditorView-CWCzo4aE.js +1 -0
  17. package/src/ui/public/assets/EditorView-DnMiWg3T.css +1 -0
  18. package/src/ui/public/assets/FileDiffViewer-7HZ_CW13.js +14 -0
  19. package/src/ui/public/assets/FileDiffViewer-B6zy6NiA.css +1 -0
  20. package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +1 -0
  21. package/src/ui/public/assets/FlowExecutionViewer-YKYhtKmd.js +1 -0
  22. package/src/ui/public/assets/FlowOrchestrationWorkspace-Bg5pSrGY.js +21 -0
  23. package/src/ui/public/assets/FlowOrchestrationWorkspace-CM2_9TW3.css +1 -0
  24. package/src/ui/public/assets/IconButton-CyEyp9_D.js +1 -0
  25. package/src/ui/public/assets/IconButton-W2X09OKH.css +1 -0
  26. package/src/ui/public/assets/LogList-B0YcWT9U.js +7 -0
  27. package/src/ui/public/assets/LogList-C7q0Y4J_.css +1 -0
  28. package/src/ui/public/assets/MindmapView-C4xg3TwX.css +1 -0
  29. package/src/ui/public/assets/MindmapView-CJI789Gz.js +1 -0
  30. package/src/ui/public/assets/MonitorView-BWOQfPtI.css +1 -0
  31. package/src/ui/public/assets/MonitorView-DVrInkz0.js +1 -0
  32. package/src/ui/public/assets/ProjectStartupButton-BGrtaPiJ.js +1 -0
  33. package/src/ui/public/assets/ProjectStartupButton-VoQqFee4.css +1 -0
  34. package/src/ui/public/assets/RecentDirectoriesChat-BsM5qfRX.js +1 -0
  35. package/src/ui/public/assets/RecentDirectoriesChat-vHT8jMxW.css +1 -0
  36. package/src/ui/public/assets/RemoteManagerDialog-BSOph2h2.css +1 -0
  37. package/src/ui/public/assets/RemoteManagerDialog-fGsXehZF.js +1 -0
  38. package/src/ui/public/assets/RemoteRepoCard-CG9WPcA_.css +1 -0
  39. package/src/ui/public/assets/RemoteRepoCard-CYXYYtM8.js +1 -0
  40. package/src/ui/public/assets/SourceMapView-BNyHS4uU.js +3 -0
  41. package/src/ui/public/assets/SourceMapView-CO_BZJKu.css +1 -0
  42. package/src/ui/public/assets/{SvgIcon-CDNsk4-Q.css → SvgIcon-BQ6bR7l0.css} +1 -1
  43. package/src/ui/public/assets/SvgIcon-Cgmy9qD6.js +1 -0
  44. package/src/ui/public/assets/TemplateManager-DUPI3yBg.js +1 -0
  45. package/src/ui/public/assets/TemplateManager-DiM3vrbf.css +1 -0
  46. package/src/ui/public/assets/UserInputNode-DUbPMxsr.js +1 -0
  47. package/src/ui/public/assets/UserInputNode-d-oRXM_J.css +1 -0
  48. package/src/ui/public/assets/WorkbenchView-AbJMHSJy.js +6 -0
  49. package/src/ui/public/assets/WorkbenchView-D8wqeEiu.css +1 -0
  50. package/src/ui/public/assets/{_plugin-vue_export-helper-o8uPiAjs.js → _plugin-vue_export-helper-DBFEfstn.js} +5 -5
  51. package/src/ui/public/assets/{agentConversations-BmOPFH_t.js → agentConversations-CuFU_pdW.js} +1 -1
  52. package/src/ui/public/assets/configStore-6xFSX9E5.js +1 -0
  53. package/src/ui/public/assets/element-plus-Bx3j6vzG.js +28 -0
  54. package/src/ui/public/assets/{flow-mindmap-C22NSZc1.js → flow-mindmap-OPE_D4da.js} +34 -34
  55. package/src/ui/public/assets/{gitStore-BI_ujQ9r.js → gitStore-BoFStoL3.js} +1 -1
  56. package/src/ui/public/assets/index-BVkLqLHo.js +29 -0
  57. package/src/ui/public/assets/index-DVJITdth.css +1 -0
  58. package/src/ui/public/assets/office-docx-B0sJQnr-.js +31 -0
  59. package/src/ui/public/assets/{office-excel-BOjWK-1x.js → office-excel-DMRySOdK.js} +1 -1
  60. package/src/ui/public/assets/office-pptx-Do2blxao.js +60 -0
  61. package/src/ui/public/assets/previewSandbox-BzitUMhT.js +9 -0
  62. package/src/ui/public/assets/{previewSandbox-PYnM6Paz.css → previewSandbox-SqNQvuHv.css} +1 -1
  63. package/src/ui/public/assets/socket-io-TBtw0msS.js +1 -0
  64. package/src/ui/public/assets/{toolsStore-DgqU92bI.js → toolsStore-B49mCaQ_.js} +1 -1
  65. package/src/ui/public/assets/{useThemeObserver-CiUpR4zj.js → useThemeObserver-DYxFoHh1.js} +1 -1
  66. package/src/ui/public/assets/{vendor-CPpc-p6F.js → vendor-CyWsJd6S.js} +45 -52
  67. package/src/ui/public/assets/vue-flow-fVVGZspa.js +39 -0
  68. package/src/ui/public/index.html +24 -24
  69. package/src/ui/server/index.js +8 -0
  70. package/src/ui/server/routes/config.js +4 -1
  71. package/src/ui/server/routes/workbench/index.js +165 -7
  72. package/src/ui/server/routes/workbench/jobActivity.js +254 -0
  73. package/src/ui/server/routes/workbench/jobActivity.test.js +270 -0
  74. package/src/ui/server/routes/workbench/jobStore.js +141 -48
  75. package/src/ui/server/routes/workbench/jobStore.test.js +139 -2
  76. package/src/ui/server/routes/workbench/liveJobs.js +229 -0
  77. package/src/ui/server/routes/workbench/liveJobs.test.js +215 -0
  78. package/src/ui/server/routes/workbench/orchestratorStore.js +164 -2
  79. package/src/ui/server/routes/workbench/orchestratorStore.progressReport.test.js +177 -0
  80. package/src/ui/server/routes/workbench/progressReport.js +300 -0
  81. package/src/ui/server/routes/workbench/progressReport.test.js +375 -0
  82. package/src/ui/server/routes/workbench/projectRegistry.js +28 -2
  83. package/src/ui/server/routes/workbench/projectRegistry.test.js +72 -0
  84. package/src/ui/server/routes/workbench/shared.js +28 -0
  85. package/src/ui/server/routes/workbench/taskRunner.js +17 -0
  86. package/src/utils/selfUpdatePolicy.js +123 -0
  87. package/src/utils/selfUpdatePolicy.test.js +204 -0
  88. package/src/ui/public/assets/AgentEngineSelector-DIn2efUS.js +0 -2
  89. package/src/ui/public/assets/AgentEngineSelector-DUf0TfaU.css +0 -1
  90. package/src/ui/public/assets/AgentView-CS-sFe-x.js +0 -10
  91. package/src/ui/public/assets/AgentView-CWCc_b0o.css +0 -1
  92. package/src/ui/public/assets/AppVersionBadge-CeHsanRP.js +0 -2
  93. package/src/ui/public/assets/BranchSelector-CxEoHp2q.js +0 -1
  94. package/src/ui/public/assets/CommitForm-4kI2csgs.css +0 -1
  95. package/src/ui/public/assets/CommitForm-DOfYuDqr.js +0 -6
  96. package/src/ui/public/assets/CommonDialog-CZ5McwHT.js +0 -1
  97. package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +0 -1
  98. package/src/ui/public/assets/EditorView-BZjWMYYl.css +0 -1
  99. package/src/ui/public/assets/EditorView-BxRrHGUj.js +0 -31
  100. package/src/ui/public/assets/FileDiffViewer-CKcSkMAd.js +0 -15
  101. package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +0 -1
  102. package/src/ui/public/assets/FlowExecutionViewer-DShoSVO7.js +0 -1
  103. package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +0 -1
  104. package/src/ui/public/assets/FlowOrchestrationWorkspace-BkPZZ0-D.js +0 -21
  105. package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +0 -1
  106. package/src/ui/public/assets/IconButton-D-maDWg7.js +0 -1
  107. package/src/ui/public/assets/IconButton-RlXqKOeu.css +0 -1
  108. package/src/ui/public/assets/LogList-Bv5ZqdMf.js +0 -7
  109. package/src/ui/public/assets/LogList-XspUhx-C.css +0 -1
  110. package/src/ui/public/assets/MindmapView-C27U_5_C.js +0 -2
  111. package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +0 -1
  112. package/src/ui/public/assets/MonitorView-BudY0fTU.js +0 -1
  113. package/src/ui/public/assets/MonitorView-DwUwJXnY.css +0 -1
  114. package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +0 -1
  115. package/src/ui/public/assets/ProjectStartupButton-YJPcfS3t.js +0 -1
  116. package/src/ui/public/assets/RecentDirectoriesChat-Bqd_4eU5.js +0 -1
  117. package/src/ui/public/assets/RecentDirectoriesChat-CZD_IC5M.css +0 -1
  118. package/src/ui/public/assets/RemoteManagerDialog-BUNLKNem.js +0 -4
  119. package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +0 -1
  120. package/src/ui/public/assets/RemoteRepoCard-Bq1oLUnZ.js +0 -7
  121. package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +0 -1
  122. package/src/ui/public/assets/SourceMapView-BCrE4CvT.js +0 -3
  123. package/src/ui/public/assets/SourceMapView-CfDydtsu.css +0 -1
  124. package/src/ui/public/assets/SvgIcon-z4ExzvGR.js +0 -1
  125. package/src/ui/public/assets/TemplateManager-BSq-dNvW.js +0 -1
  126. package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +0 -1
  127. package/src/ui/public/assets/UserInputNode-BzbaR9lE.js +0 -1
  128. package/src/ui/public/assets/UserInputNode-DDNSarag.css +0 -1
  129. package/src/ui/public/assets/WorkbenchView-BJ7lMt3C.js +0 -70
  130. package/src/ui/public/assets/WorkbenchView-DGIn0UR4.css +0 -1
  131. package/src/ui/public/assets/configStore-DrMJDegh.js +0 -1
  132. package/src/ui/public/assets/element-plus-AaVXaSqv.js +0 -48
  133. package/src/ui/public/assets/index-Cp5WtCfY.css +0 -1
  134. package/src/ui/public/assets/index-D_Pwvey4.js +0 -130
  135. package/src/ui/public/assets/office-docx-CJ6JSCs6.js +0 -32
  136. package/src/ui/public/assets/office-pptx-CF43ZwXE.js +0 -74
  137. package/src/ui/public/assets/previewSandbox-B16xvZLS.js +0 -9
  138. package/src/ui/public/assets/socket-io-BFqQXqAp.js +0 -1
  139. package/src/ui/public/assets/vue-flow-DwYB6Myr.js +0 -39
@@ -14,12 +14,17 @@
14
14
  //
15
15
  // 主 Agent 编排台的状态存储 + 活动流拼装。
16
16
  //
17
- // 三件事:
17
+ // 四件事:
18
18
  // 1. 调度开关(active)+ 人类干预指令存档,持久化到 ~/.zen-gitsync/orchestrator.json。
19
19
  // 「暂停调度」是服务端强制的:暂停期间自动派发会被拒绝(手动点执行不受影响)。
20
20
  // 2. 派发默认提示词:一条全局的 + 每个项目一条的,同样存在 orchestrator.json。
21
21
  // 它们是「派发时自动附加的约束」,不是任务内容 —— 解析规则见 resolveDispatchPrompt。
22
- // 3. buildActivityFeed —— 把 job 的起止事实与人类指令合成一条时间倒序的活动流。
22
+ // 3. 进度报告:自动报告的间隔配置存在 orchestrator.json(一个数字,跟着轮询下发),
23
+ // 历史报告存在 orchestrator-reports.json(几 KB 一份,只在面板打开时按需取)。
24
+ // 报告正文由 progressReport.js 生成,这里只负责"存哪、存几份、什么时候允许再生成"。
25
+ // 4. buildActivityFeed —— 把 job 的起止事实与人类指令合成一条时间倒序的活动流。
26
+ // (控制台右栏的「活动日志」已换成进度报告,但这条流还在供顶栏「今日完成」计数,
27
+ // 以及编排台接口的数据完整性 —— 别看到没人渲染就把它删了。)
23
28
  //
24
29
  // 活动流为什么不在服务端拼好文案:
25
30
  // UI 文案统一走前端 i18n(lang/{zh,en}),服务端只回**结构化事实**
@@ -31,8 +36,12 @@
31
36
 
32
37
  import {
33
38
  ORCHESTRATOR_FILE,
39
+ ORCHESTRATOR_REPORTS_FILE,
34
40
  MAX_ORCHESTRATOR_INSTRUCTIONS,
35
41
  MAX_DEFAULT_PROMPT_CHARS,
42
+ MAX_PROGRESS_REPORTS,
43
+ PROGRESS_REPORT_INTERVALS_MS,
44
+ DEFAULT_PROGRESS_REPORT_INTERVAL_MS,
36
45
  readJson,
37
46
  writeJson,
38
47
  nowIso,
@@ -56,9 +65,20 @@ function defaultState() {
56
65
  instructions: [],
57
66
  defaultPrompt: '',
58
67
  projectPrompts: {},
68
+ reportIntervalMs: DEFAULT_PROGRESS_REPORT_INTERVAL_MS,
69
+ lastReportAt: null,
59
70
  };
60
71
  }
61
72
 
73
+ /**
74
+ * 报告间隔归一。**只认白名单里的值**,其余(缺字段 / 脏值 / 手改文件写进去的 7 分钟)
75
+ * 一律回落到默认 10 分钟。老版本没有这个字段 —— 那正是"升级后按新默认开始报告"的语义。
76
+ */
77
+ export function normalizeReportIntervalMs(value) {
78
+ const n = Number(value);
79
+ return PROGRESS_REPORT_INTERVALS_MS.includes(n) ? n : DEFAULT_PROGRESS_REPORT_INTERVAL_MS;
80
+ }
81
+
62
82
  /** 单条指令归一:老数据/半损坏数据都要能渲染,不能让整份状态读不出来 */
63
83
  function normalizeInstruction(raw) {
64
84
  const it = raw && typeof raw === 'object' ? raw : {};
@@ -122,6 +142,8 @@ export function normalizeOrchestrator(raw) {
122
142
  ? state.defaultPrompt.slice(0, MAX_DEFAULT_PROMPT_CHARS)
123
143
  : '',
124
144
  projectPrompts: normalizeProjectPrompts(state.projectPrompts),
145
+ reportIntervalMs: normalizeReportIntervalMs(state.reportIntervalMs),
146
+ lastReportAt: typeof state.lastReportAt === 'string' ? state.lastReportAt : null,
125
147
  };
126
148
  }
127
149
 
@@ -258,6 +280,146 @@ export async function clearInstructions() {
258
280
  });
259
281
  }
260
282
 
283
+ // ── 进度报告的配置与历史 ────────────────────────────────────────────────
284
+ // 「报告时间我自己设置」的那个设置:0 = 关掉自动报告(手动「立即报告」始终可用,
285
+ // 它不受这个开关影响 —— 关掉的是"自己隔一会儿跑一次",不是"不许报告")。
286
+
287
+ /** 写自动报告间隔。返回写后的值(已归一) */
288
+ export async function setReportIntervalMs(value) {
289
+ return serialize(async () => {
290
+ const state = await readOrchestrator();
291
+ state.reportIntervalMs = normalizeReportIntervalMs(value);
292
+ state.updatedAt = nowIso();
293
+ await persist(state);
294
+ return state.reportIntervalMs;
295
+ });
296
+ }
297
+
298
+ /**
299
+ * 抢占一次自动报告的"名额":距上次报告不足 intervalMs 就返回 false。
300
+ *
301
+ * 为什么占坑要写盘而不是只放内存里一个时间戳:报告历史是**多实例共享**的
302
+ * (同一个 ~/.zen-gitsync 可能同时开着两个 g ui),各自跑一个定时器的话,
303
+ * 内存里各记各的,两边会在同一分钟各生成一份内容几乎一样的报告。
304
+ * 时间戳落在共享文件上,后到的那个实例读到的就是"刚报过"。
305
+ *
306
+ * 抢占发生在**生成之前**(生成要等一次模型往返,几秒到几十秒):
307
+ * 等生成完再写,两个实例的窗口就大得多。代价是生成失败也要等满一个间隔才重试 ——
308
+ * 失败会记进报告历史(errorCode),比"同一分钟内连打两次模型"划算。
309
+ */
310
+ export async function claimReportSlot(intervalMs, now = Date.now()) {
311
+ return serialize(async () => {
312
+ const state = await readOrchestrator();
313
+ const last = Date.parse(state.lastReportAt || '');
314
+ if (Number.isFinite(last) && now - last < intervalMs) return false;
315
+ state.lastReportAt = new Date(now).toISOString();
316
+ await persist(state);
317
+ return true;
318
+ });
319
+ }
320
+
321
+ /** 单份报告归一:字段缺失 / 脏数据都必须能渲染,不能让面板整个读不出来 */
322
+ function normalizeReport(raw) {
323
+ const r = raw && typeof raw === 'object' ? raw : {};
324
+ const tasks = Array.isArray(r.tasks) ? r.tasks : [];
325
+ return {
326
+ id: typeof r.id === 'string' && r.id ? r.id : genId(),
327
+ at: typeof r.at === 'string' ? r.at : null,
328
+ trigger: r.trigger === 'auto' ? 'auto' : 'manual',
329
+ text: typeof r.text === 'string' ? r.text : '',
330
+ errorCode: typeof r.errorCode === 'string' ? r.errorCode : '',
331
+ errorDetail: typeof r.errorDetail === 'string' ? r.errorDetail : '',
332
+ tasks: tasks
333
+ .filter(t => t && typeof t === 'object')
334
+ .map(t => ({
335
+ taskId: typeof t.taskId === 'string' ? t.taskId : null,
336
+ taskTitle: typeof t.taskTitle === 'string' ? t.taskTitle : '',
337
+ projectName: typeof t.projectName === 'string' ? t.projectName : '',
338
+ startedAt: typeof t.startedAt === 'string' ? t.startedAt : null,
339
+ // 跑多久是**生成那一刻**的事实,跟着报告存下来:
340
+ // 前端拿现在的时钟去减 startedAt,"已运行 12 分钟"会随报告变旧一路涨,
341
+ // 历史报告就再也说不清"当时是什么情况"了。
342
+ elapsedMs: Number.isFinite(Number(t.elapsedMs)) ? Math.max(0, Math.floor(Number(t.elapsedMs))) : 0,
343
+ agent: typeof t.agent === 'string' ? t.agent : '',
344
+ toolCallCount: Number.isFinite(Number(t.toolCallCount)) ? Math.max(0, Math.floor(Number(t.toolCallCount))) : 0,
345
+ lastTool: typeof t.lastTool === 'string' ? t.lastTool : '',
346
+ lastLine: typeof t.lastLine === 'string' ? t.lastLine : '',
347
+ // 2026-09-29 补的三样:老记录里没有它们 —— 一律给"没有"('' / null),
348
+ // 不要硬塞一个默认值冒充"当时它就是在思考 / 当时静默了 0 秒"
349
+ toolMix: typeof t.toolMix === 'string' ? t.toolMix : '',
350
+ lastThought: typeof t.lastThought === 'string' ? t.lastThought : '',
351
+ silentMs: Number.isFinite(Number(t.silentMs)) ? Math.max(0, Math.floor(Number(t.silentMs))) : null,
352
+ })),
353
+ };
354
+ }
355
+
356
+ /**
357
+ * 读报告历史(新的在前)。文件缺失 / 损坏一律当"还没有报告",不抛错。
358
+ *
359
+ * **没有事实的报告不算报告**:那是一条"当时什么都没在跑"的空记录,对回头翻历史的人
360
+ * 零信息量,却要占掉上限 20 条里的一格,把真正有用的挤出去(用户 2026-09-29 的原话:
361
+ * "这种没有在执行的就不用展示了")。
362
+ *
363
+ * 写入侧已经不再产生它们(见 routes/workbench/index.js 的 runProgressReport),
364
+ * 这里再挡一次是因为:老版本进程 / 另一个实例写进同一份文件的空报告,
365
+ * 在读取方看来同样得当成不存在 —— 面板只认这个函数给的清单。
366
+ * 另外 appendReport 是拿本函数的返回值重写整个文件的,所以历史遗留的空报告
367
+ * 会在下一次落盘时被真正从磁盘上清掉。
368
+ */
369
+ export async function readReports() {
370
+ const data = await readJson(ORCHESTRATOR_REPORTS_FILE, null);
371
+ const list = data && Array.isArray(data.reports) ? data.reports : [];
372
+ return list
373
+ .map(normalizeReport)
374
+ .filter(r => r.tasks.length > 0)
375
+ .sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
376
+ .slice(0, MAX_PROGRESS_REPORTS);
377
+ }
378
+
379
+ /** 两份报告"说的是同一件事"吗:同一批任务 + 同一段正文 + 同一种失败 */
380
+ function sameReport(a, b) {
381
+ if (!a || !b) return false;
382
+ if (a.trigger !== b.trigger || a.text !== b.text) return false;
383
+ if (a.errorCode !== b.errorCode || a.errorDetail !== b.errorDetail) return false;
384
+ if (a.tasks.length !== b.tasks.length) return false;
385
+ return a.tasks.every((t, i) => (t.taskId || '') === (b.tasks[i].taskId || '')
386
+ && (t.taskTitle || '') === (b.tasks[i].taskTitle || ''));
387
+ }
388
+
389
+ /**
390
+ * 追加一份报告(新的在前),超出上限的旧报告直接丢掉。
391
+ *
392
+ * 窗口期内**内容一样的**只留第一份。为什么需要它:没有正文的报告(没配模型 = NO_MODEL)
393
+ * 是逐字节相同的 —— 「立即报告」连点两下、或两个实例各点一下,历史里就会出现两条
394
+ * 一模一样的记录(真实数据:2026-09-28T15:44:08.161 与 .226 两条 manual / tasks=0)。
395
+ * 前端那个"生成中"的防抖挡不住这种点击:空报告不叫模型,本机往返几十毫秒就返回了,
396
+ * 第二次点击落在防抖释放之后。
397
+ *
398
+ * 判据刻意**不含**时长 / 工具次数 / 最近输出这些每份报告都会变的字段 ——
399
+ * 带上它们就永远"不算重复",等于没做这个检查。
400
+ */
401
+ const REPORT_DEDUPE_WINDOW_MS = 10 * 1000;
402
+
403
+ /** @returns {Promise<object>} 落盘的那一份;被判为重复时返回已在历史里的头部那条 */
404
+ export async function appendReport(entry) {
405
+ return serialize(async () => {
406
+ const report = normalizeReport({ id: genId(), at: nowIso(), ...entry });
407
+ const existing = await readReports();
408
+ const head = existing[0];
409
+ const headAt = Date.parse((head && head.at) || '');
410
+ if (head && Number.isFinite(headAt)
411
+ && Date.now() - headAt < REPORT_DEDUPE_WINDOW_MS
412
+ && sameReport(head, report)) {
413
+ // 不落盘。返回头部那条而不是 null:调用方(runProgressReport)要拿它广播,
414
+ // 前端本来就在展示它,等于"你看到的就是刚生成的"
415
+ return head;
416
+ }
417
+ const reports = [report, ...existing].slice(0, MAX_PROGRESS_REPORTS);
418
+ await writeJson(ORCHESTRATOR_REPORTS_FILE, { version: 1, reports });
419
+ return report;
420
+ });
421
+ }
422
+
261
423
  /**
262
424
  * 正在执行的执行体清单(看板左栏「执行监控」用)。
263
425
  *
@@ -0,0 +1,177 @@
1
+ // Copyright 2026 xz333221
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ //
15
+
16
+ // 进度报告**历史文件**的两条规则(读写都在 orchestratorStore 里):
17
+ //
18
+ // 1. 没有事实的报告不算报告 —— 读的时候当不存在,写的时候也活不过下一次落盘。
19
+ // 用户 2026-09-29 的原话:"这种没有在执行的就不用展示了":那 4 条
20
+ // "当时没有任务在执行"既零信息量,又占着 20 条上限的格子。
21
+ // 2. 窗口期内**内容一样**的只留第一份 —— 没有正文的报告(NO_MODEL)逐字节相同,
22
+ // 「立即报告」连点两下就会在历史里留下两条一模一样的记录
23
+ // (真实数据:2026-09-28T15:44:08.161 与 .226,两条都是 manual / tasks=0)。
24
+ //
25
+ // 隔离:必须在 import 之前把 USERPROFILE/HOME 指向 mkdtemp 沙箱,否则就是往用户真实的
26
+ // ~/.zen-gitsync 里写报告(同 dispatchInstruction.test.js / jobStore.test.js 的做法)。
27
+
28
+ import { test, before, after } from 'node:test';
29
+ import assert from 'node:assert/strict';
30
+ import { promises as fs } from 'node:fs';
31
+ import path from 'node:path';
32
+ import os from 'node:os';
33
+ import { fileURLToPath, pathToFileURL } from 'node:url';
34
+
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ const projectRoot = path.resolve(__dirname, '../../../../..');
37
+
38
+ // ---- 环境隔离:必须在 import 之前完成 ----
39
+ const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'zgs-report-store-test-'));
40
+ const savedEnv = {
41
+ USERPROFILE: process.env.USERPROFILE,
42
+ HOME: process.env.HOME,
43
+ HOMEDRIVE: process.env.HOMEDRIVE,
44
+ HOMEPATH: process.env.HOMEPATH,
45
+ };
46
+ process.env.USERPROFILE = fakeHome;
47
+ process.env.HOME = fakeHome;
48
+ delete process.env.HOMEDRIVE;
49
+ delete process.env.HOMEPATH;
50
+
51
+ const load = p => import(pathToFileURL(path.join(projectRoot, p)).href);
52
+ const { readReports, appendReport } = await load('src/ui/server/routes/workbench/orchestratorStore.js');
53
+ const { ORCHESTRATOR_REPORTS_FILE, readJson } = await load('src/ui/server/routes/workbench/shared.js');
54
+
55
+ after(async () => {
56
+ for (const k of Object.keys(savedEnv)) {
57
+ if (savedEnv[k] === undefined) delete process.env[k];
58
+ else process.env[k] = savedEnv[k];
59
+ }
60
+ await fs.rm(fakeHome, { recursive: true, force: true }).catch(() => {});
61
+ });
62
+
63
+ /** 一份任务事实的最小形状(normalizeReport 认得的那几个字段) */
64
+ const fact = (taskId, taskTitle) => ({
65
+ taskId,
66
+ taskTitle,
67
+ projectName: 'zen-gitsync',
68
+ startedAt: null,
69
+ elapsedMs: 1000,
70
+ agent: 'claude',
71
+ toolCallCount: 3,
72
+ lastTool: 'Edit src/a.ts',
73
+ lastLine: '正在改登录模块',
74
+ });
75
+
76
+ /** 手写历史文件:直接落盘才能构造"30 秒前"这种 appendReport 自己写不出来的时间 */
77
+ async function seed(reports) {
78
+ await fs.mkdir(path.dirname(ORCHESTRATOR_REPORTS_FILE), { recursive: true });
79
+ await fs.writeFile(ORCHESTRATOR_REPORTS_FILE, JSON.stringify({ version: 1, reports }, null, 2), 'utf8');
80
+ }
81
+
82
+ /** 磁盘上的原始内容(不走 readReports 的过滤),用来验证"真的清掉了" */
83
+ async function onDisk() {
84
+ const data = await readJson(ORCHESTRATOR_REPORTS_FILE, null);
85
+ return (data && Array.isArray(data.reports)) ? data.reports : [];
86
+ }
87
+
88
+ const iso = (msAgo) => new Date(Date.now() - msAgo).toISOString();
89
+
90
+ const rawReport = (over = {}) => ({
91
+ id: 'r-' + Math.random().toString(36).slice(2, 8),
92
+ at: iso(0),
93
+ trigger: 'manual',
94
+ text: '',
95
+ errorCode: '',
96
+ errorDetail: '',
97
+ tasks: [],
98
+ ...over,
99
+ });
100
+
101
+ before(async () => { await seed([]); });
102
+
103
+ test('空报告读不出来("这种没有在执行的就不用展示了")', async () => {
104
+ await seed([
105
+ rawReport({ id: 'with-facts', at: iso(60_000), tasks: [fact('t1', '有事实的任务')] }),
106
+ rawReport({ id: 'empty-1', at: iso(30_000) }),
107
+ rawReport({ id: 'empty-2', at: iso(20_000) }),
108
+ ]);
109
+ const list = await readReports();
110
+ assert.deepEqual(list.map(r => r.id), ['with-facts']);
111
+ });
112
+
113
+ test('空报告不再占用 20 条上限:读出来的是最近 20 条**真**报告', async () => {
114
+ const reports = [];
115
+ // 交错写:每两条真报告中间夹一条空报告,真报告共 21 条(超上限 1 条)
116
+ for (let i = 0; i < 21; i++) {
117
+ reports.push(rawReport({ id: `real-${i}`, at: iso((21 - i) * 1000), tasks: [fact(`t${i}`, `任务 ${i}`)] }));
118
+ reports.push(rawReport({ id: `empty-${i}`, at: iso((21 - i) * 1000 + 500) }));
119
+ }
120
+ await seed(reports);
121
+ const list = await readReports();
122
+ assert.equal(list.length, 20);
123
+ assert.ok(list.every(r => r.tasks.length > 0), '读出来的每一条都得有事实');
124
+ // 新的在前:real-20 最新,real-0 被上限挤掉
125
+ assert.equal(list[0].id, 'real-20');
126
+ assert.ok(!list.some(r => r.id === 'real-0'));
127
+ });
128
+
129
+ test('历史遗留的空报告在下一次落盘时从磁盘上清掉', async () => {
130
+ await seed([rawReport({ id: 'legacy-empty', at: iso(60_000) })]);
131
+ await appendReport(rawReport({ id: 'ignored', trigger: 'auto', tasks: [fact('t1', '任务')] }));
132
+ const disk = await onDisk();
133
+ assert.equal(disk.length, 1);
134
+ assert.ok(!disk.some(r => r.id === 'legacy-empty'), '空报告应该已经从文件里消失');
135
+ });
136
+
137
+ test('窗口期内内容一样的第二份不落盘(连点两下的去重)', async () => {
138
+ await seed([]);
139
+ const first = await appendReport(rawReport({ errorCode: 'NO_MODEL', tasks: [fact('t1', '任务 A')] }));
140
+ const second = await appendReport(rawReport({ errorCode: 'NO_MODEL', tasks: [fact('t1', '任务 A')] }));
141
+ assert.equal(second.id, first.id, '第二份应该被判为重复,返回已有的那份');
142
+ assert.equal((await onDisk()).length, 1);
143
+ });
144
+
145
+ test('同一批任务但正文不同 → 两份都留(不是"同一批任务就合并")', async () => {
146
+ await seed([]);
147
+ const base = { tasks: [fact('t1', '任务 A')] };
148
+ await appendReport(rawReport({ ...base, text: '第一次汇报:正在改登录模块。' }));
149
+ await appendReport(rawReport({ ...base, text: '第二次汇报:已经在跑测试了。' }));
150
+ assert.equal((await onDisk()).length, 2);
151
+ });
152
+
153
+ test('过了去重窗口那份就不再算重复(同一分钟连点两下之外是正常的两份)', async () => {
154
+ // 手写一条 30 秒前的报告,再用相同内容 append —— 窗口(10s)已经过去
155
+ const stale = rawReport({ id: 'stale', at: iso(30_000), errorCode: 'NO_MODEL', tasks: [fact('t1', '任务 A')] });
156
+ await seed([stale]);
157
+ const saved = await appendReport(rawReport({ errorCode: 'NO_MODEL', tasks: [fact('t1', '任务 A')] }));
158
+ assert.notEqual(saved.id, 'stale');
159
+ assert.equal((await onDisk()).length, 2);
160
+ });
161
+
162
+ test('触发的来源不同(手动 vs 自动)不算重复', async () => {
163
+ await seed([]);
164
+ const tasks = [fact('t1', '任务 A')];
165
+ await appendReport(rawReport({ trigger: 'manual', tasks }));
166
+ await appendReport(rawReport({ trigger: 'auto', tasks }));
167
+ assert.equal((await onDisk()).length, 2);
168
+ });
169
+
170
+ test('上限仍然生效:多余的报告按时间从旧到新丢掉', async () => {
171
+ await seed([]);
172
+ for (let i = 0; i < 23; i++) {
173
+ // 每份正文都不同,绕开去重窗口
174
+ await appendReport(rawReport({ text: `第 ${i} 次汇报`, tasks: [fact('t1', '任务 A')] }));
175
+ }
176
+ assert.equal((await onDisk()).length, 20);
177
+ });