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
@@ -0,0 +1,270 @@
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
+ // 「这个 job 现在在干嘛」的纯函数单测。
16
+ //
17
+ // 这些函数过去挂在 progressReport.js 上(只喂给主 Agent 写汇报),2026-09-29 起看板
18
+ // 「进行中」卡片直接把它们的结果显示给人看 —— 断言重心因此变成:**卡片上那句话是不是
19
+ // 真话**。挑错 job(拿一条早就结束的)/ 把空串渲染成"暂无" / 静默没到阈值就报"卡住了",
20
+ // 在界面上都是"看起来很正常"的错,只有单测挡得住。
21
+ //
22
+ // 前四组(tailLine / describeLastTool / describeToolMix / silentMsOf)随实现一起从
23
+ // progressReport.test.js 搬过来,断言原样保留。
24
+
25
+ import { test } from 'node:test';
26
+ import assert from 'node:assert/strict';
27
+
28
+ import {
29
+ tailLine,
30
+ tailExcerpt,
31
+ describeLastTool,
32
+ describeToolMix,
33
+ silentMsOf,
34
+ buildLiveActivity,
35
+ pickLiveActivity,
36
+ TOOL_MIX_WINDOW,
37
+ SILENT_NOTABLE_MS,
38
+ } from './jobActivity.js';
39
+
40
+ // ── tailLine ────────────────────────────────────────────────────────────
41
+
42
+ test('tailLine 取最后一行有效文本,跳过末尾空行', () => {
43
+ assert.equal(tailLine('第一行\n第二行\n\n \n'), '第二行');
44
+ });
45
+
46
+ test('tailLine 把行内空白折成一个空格(多行输出里的缩进不该原样进 prompt / 卡片)', () => {
47
+ assert.equal(tailLine('a\n 正在 编辑\t文件 '), '正在 编辑 文件');
48
+ });
49
+
50
+ test('tailLine 截断超长行,且空 / 非字符串一律返回空串', () => {
51
+ assert.equal(tailLine('x'.repeat(500)).length, 160);
52
+ assert.equal(tailLine(''), '');
53
+ assert.equal(tailLine(null), '');
54
+ assert.equal(tailLine(undefined), '');
55
+ });
56
+
57
+ // ── tailExcerpt(跑完之后卡片上那段「最后说了什么」) ──────────────────────
58
+ // 这段摘录要回答的是"这条已完成的任务,是不是其实在等我回话",所以断言的重心是
59
+ // **结尾那句必须还在**:模型收尾时那句「要 push 吗?」被截掉的话,这个功能就白做了。
60
+
61
+ test('tailExcerpt 取尾部一段,压平成一行并去掉 markdown 标记', () => {
62
+ const out = '## 改动\n\n- **新增** `lastReply` 字段\n- 卡片上显示最后回复\n\n要 push 吗?';
63
+ // 标题 / 列表标记 / ** / 反引号都只是噪声;卡片上没有富文本,留着一堆符号没法读
64
+ assert.equal(tailExcerpt(out), '改动 新增 lastReply 字段 卡片上显示最后回复 要 push 吗?');
65
+ });
66
+
67
+ test('tailExcerpt 丢掉表格行与分隔线(压成一行后只剩一堆竖线)', () => {
68
+ const out = '## 验证\n\n| 项 | 结果 |\n|---|---|\n| vue-tsc | 0 错误 |\n\n已提交。要 push 吗?';
69
+ assert.equal(tailExcerpt(out), '验证 已提交。要 push 吗?');
70
+ });
71
+
72
+ test('tailExcerpt 真截断过才加 …(结尾那句永远保留)', () => {
73
+ const long = `${'前'.repeat(300)}收尾那句:要 push 吗?`;
74
+ const got = tailExcerpt(long);
75
+ assert.ok(got.startsWith('…'), '从中间开始就该有 … 标记');
76
+ assert.ok(got.endsWith('要 push 吗?'), '结尾那句不能被截掉 —— CSS 截的是尾巴,这是它被截的原因');
77
+ // 上限按"去掉 … 之后"算:… 不计入 100 字的预算
78
+ assert.equal(got.length, 101);
79
+ });
80
+
81
+ test('tailExcerpt 没截断就不加 …(… 是"这句话从中间开始"的标记,不能乱标)', () => {
82
+ assert.equal(tailExcerpt('你好!有什么可以帮你的?'), '你好!有什么可以帮你的?');
83
+ // 短输出连第一行一起保留:窗口没切,第一行是完整的一句,不是半截
84
+ assert.equal(tailExcerpt('你好!\n\n需要的话我可以读本机的看板文件。'), '你好! 需要的话我可以读本机的看板文件。');
85
+ });
86
+
87
+ test('tailExcerpt 窗口起点落在半句中间时从下一个整行开始(摘录不以半句开头)', () => {
88
+ const out = `${'前'.repeat(5000)}\n完整的一行\n要 push 吗?`;
89
+ // 5000 字 > REPLY_WINDOW_CHARS:被切掉的首行本就是半句,丢掉而不是拼进来
90
+ assert.equal(tailExcerpt(out), '完整的一行 要 push 吗?');
91
+ });
92
+
93
+ test('tailExcerpt 单行超长输出也能给出尾部(窗口里没有换行可退)', () => {
94
+ const got = tailExcerpt(`${'字'.repeat(5000)}要 push 吗?`);
95
+ assert.ok(got.startsWith('…'));
96
+ assert.ok(got.endsWith('要 push 吗?'));
97
+ });
98
+
99
+ test('tailExcerpt 空输出 / 脏值 / 全是表格噪声时给空串(前端据此不渲染这一段)', () => {
100
+ assert.equal(tailExcerpt(''), '');
101
+ assert.equal(tailExcerpt(null), '');
102
+ assert.equal(tailExcerpt(undefined), '');
103
+ assert.equal(tailExcerpt(' \n\n '), '');
104
+ assert.equal(tailExcerpt('|---|---|\n| a | b |'), '');
105
+ });
106
+
107
+ // ── describeLastTool ────────────────────────────────────────────────────
108
+
109
+ test('describeLastTool 取最后一次带名字的调用,优先用 argsPreview', () => {
110
+ const calls = [
111
+ { name: 'Read', argsPreview: 'a.ts', arguments: '{"file_path":"a.ts"}' },
112
+ { name: 'Edit', argsPreview: 'b.ts', arguments: '{"file_path":"b.ts"}' },
113
+ ];
114
+ assert.equal(describeLastTool(calls), 'Edit b.ts');
115
+ });
116
+
117
+ test('describeLastTool 跳过没有名字的尾部记录(老数据 / 半截记录)', () => {
118
+ const calls = [{ name: 'Bash', argsPreview: 'npm test' }, { id: 'anon-1' }];
119
+ assert.equal(describeLastTool(calls), 'Bash npm test');
120
+ });
121
+
122
+ test('describeLastTool 没有 argsPreview 时退回 arguments;两者都没有就只给工具名', () => {
123
+ assert.equal(describeLastTool([{ name: 'Bash', arguments: 'ls -la' }]), 'Bash ls -la');
124
+ assert.equal(describeLastTool([{ name: 'Bash' }]), 'Bash');
125
+ assert.equal(describeLastTool([]), '');
126
+ assert.equal(describeLastTool(null), '');
127
+ });
128
+
129
+ // ── describeToolMix / silentMsOf ────────────────────────────────────────
130
+
131
+ test('describeToolMix 给出最近几次调用的分布,多的在前', () => {
132
+ const calls = [
133
+ { name: 'Bash' }, { name: 'Read' }, { name: 'Bash' },
134
+ { name: 'Read' }, { name: 'Bash' }, { name: 'Edit' },
135
+ ];
136
+ assert.equal(describeToolMix(calls), 'Bash×3 · Read×2 · Edit');
137
+ });
138
+
139
+ test('describeToolMix 只看最近 TOOL_MIX_WINDOW 次 —— 早先的模式早被淹没了', () => {
140
+ // 先 30 次 Edit,再 20 次 Read:窗口内只有 Read,分布里不该冒出 Edit
141
+ const calls = [
142
+ ...Array.from({ length: 30 }, () => ({ name: 'Edit' })),
143
+ ...Array.from({ length: TOOL_MIX_WINDOW }, () => ({ name: 'Read' })),
144
+ ];
145
+ assert.equal(describeToolMix(calls), `Read×${TOOL_MIX_WINDOW}`);
146
+ });
147
+
148
+ test('describeToolMix 跳过没有名字的记录,但它们不占窗口名额', () => {
149
+ const calls = [
150
+ { name: 'Bash' },
151
+ ...Array.from({ length: TOOL_MIX_WINDOW }, () => ({ id: 'anon-1' })),
152
+ ];
153
+ assert.equal(describeToolMix(calls), 'Bash');
154
+ });
155
+
156
+ test('describeToolMix 没有调用时给空串(调用方据此不显示这一行)', () => {
157
+ assert.equal(describeToolMix([]), '');
158
+ assert.equal(describeToolMix(null), '');
159
+ assert.equal(describeToolMix(undefined), '');
160
+ });
161
+
162
+ test('silentMsOf 到阈值才算静默,不到 / 没这个字段一律 null', () => {
163
+ const at = '2026-09-28T10:00:00.000Z';
164
+ const base = Date.parse(at);
165
+ const job = { lastActivityAt: at };
166
+ assert.equal(silentMsOf(job, base + SILENT_NOTABLE_MS), SILENT_NOTABLE_MS);
167
+ assert.equal(silentMsOf(job, base + SILENT_NOTABLE_MS - 1), null);
168
+ // 老记录 / 别的实例上的旧版进程没有这个字段:给 null,而不是编一个 0
169
+ assert.equal(silentMsOf({}, base), null);
170
+ assert.equal(silentMsOf({ lastActivityAt: '不是时间' }, base), null);
171
+ assert.equal(silentMsOf(null, base), null);
172
+ });
173
+
174
+ // ── buildLiveActivity ───────────────────────────────────────────────────
175
+
176
+ const START = '2026-09-28T10:00:00.000Z';
177
+ const NOW = Date.parse('2026-09-28T10:03:20.000Z'); // 距 START 3 分 20 秒
178
+
179
+ /** 一条"跑了 3 分 20 秒"的 job 模板 */
180
+ function liveJob(over = {}) {
181
+ return {
182
+ id: 'j1',
183
+ taskId: 't1',
184
+ status: 'running',
185
+ agent: 'claude',
186
+ startedAt: START,
187
+ thinking: '先看现状\n这个文件的锁逻辑有问题,得先弄清楚谁在什么时候写',
188
+ output: '我先读一下 login.ts',
189
+ toolCalls: [{ name: 'Read', argsPreview: 'src/login.ts' }],
190
+ ...over,
191
+ };
192
+ }
193
+
194
+ test('buildLiveActivity:终态 / 空 job 返回 null(卡片据此不显示活动区)', () => {
195
+ assert.equal(buildLiveActivity(liveJob({ status: 'done' }), NOW), null);
196
+ assert.equal(buildLiveActivity(liveJob({ status: 'error' }), NOW), null);
197
+ assert.equal(buildLiveActivity(null, NOW), null);
198
+ // pending 也算在跑(进程刚起来还没吐字):卡片上仍要给"已运行 0 秒",不能空着
199
+ assert.equal(buildLiveActivity(liveJob({ status: 'pending' }), NOW).status, 'pending');
200
+ });
201
+
202
+ test('buildLiveActivity:把思考 / 工具 / 最新回复都抽成一句话,时长按传入的 now 算', () => {
203
+ const live = buildLiveActivity(liveJob(), NOW);
204
+ assert.equal(live.elapsedMs, 3 * 60 * 1000 + 20 * 1000);
205
+ assert.equal(live.agent, 'claude');
206
+ assert.equal(live.startedAt, START);
207
+ assert.equal(live.toolCallCount, 1);
208
+ assert.equal(live.lastTool, 'Read src/login.ts');
209
+ assert.equal(live.toolMix, 'Read');
210
+ assert.equal(live.lastThought, '这个文件的锁逻辑有问题,得先弄清楚谁在什么时候写');
211
+ assert.equal(live.lastLine, '我先读一下 login.ts');
212
+ // 没有 lastActivityAt → 静默这条不成立,给 null 而不是编个 0
213
+ assert.equal(live.silentMs, null);
214
+ });
215
+
216
+ test('buildLiveActivity:老记录没有 agent / toolCalls 时给出空值,不抛错', () => {
217
+ const live = buildLiveActivity({ id: 'j2', status: 'running', startedAt: START }, NOW);
218
+ assert.equal(live.agent, '');
219
+ assert.equal(live.toolCallCount, 0);
220
+ assert.equal(live.lastTool, '');
221
+ assert.equal(live.lastThought, '');
222
+ assert.equal(live.lastLine, '');
223
+ });
224
+
225
+ test('buildLiveActivity:startedAt 缺失或脏值时 elapsedMs 记 0(不显示负数 / NaN)', () => {
226
+ assert.equal(buildLiveActivity({ id: 'j', status: 'running' }, NOW).elapsedMs, 0);
227
+ assert.equal(buildLiveActivity({ id: 'j', status: 'running', startedAt: '不是时间' }, NOW).elapsedMs, 0);
228
+ });
229
+
230
+ // ── pickLiveActivity ────────────────────────────────────────────────────
231
+
232
+ test('pickLiveActivity:一个任务有多条 job 时挑最近有动静的那条,不是跑得最久的', () => {
233
+ const live = pickLiveActivity([
234
+ { id: 'old', status: 'running', startedAt: '2026-09-28T09:00:00.000Z', output: '老的那条' },
235
+ {
236
+ id: 'fresh',
237
+ status: 'running',
238
+ startedAt: '2026-09-28T10:00:00.000Z',
239
+ lastActivityAt: '2026-09-28T10:03:00.000Z',
240
+ output: '刚吐过字的那条',
241
+ },
242
+ ], NOW);
243
+ assert.equal(live.jobId, 'fresh');
244
+ assert.equal(live.lastLine, '刚吐过字的那条');
245
+ });
246
+
247
+ test('pickLiveActivity:只看在跑的 —— 有终态 job 时不拿它顶替', () => {
248
+ const done = { id: 'done', status: 'done', startedAt: '2026-09-28T10:02:00.000Z', output: '已经结束了' };
249
+ assert.equal(pickLiveActivity([done], NOW), null);
250
+ const running = { id: 'run', status: 'running', startedAt: START, output: '还在跑' };
251
+ assert.equal(pickLiveActivity([done, running], NOW).jobId, 'run');
252
+ });
253
+
254
+ test('pickLiveActivity:时间戳打平时取末条(与 latestJob 同口径),空数组给 null', () => {
255
+ const same = [
256
+ { id: 'a', status: 'running', startedAt: START, output: 'a' },
257
+ { id: 'b', status: 'running', startedAt: START, output: 'b' },
258
+ ];
259
+ assert.equal(pickLiveActivity(same, NOW).jobId, 'b');
260
+ assert.equal(pickLiveActivity([], NOW), null);
261
+ assert.equal(pickLiveActivity(null, NOW), null);
262
+ });
263
+
264
+ test('pickLiveActivity:lastActivityAt 是脏值时退回 startedAt 比较', () => {
265
+ const live = pickLiveActivity([
266
+ { id: 'a', status: 'running', startedAt: '2026-09-28T09:00:00.000Z', lastActivityAt: '不是时间' },
267
+ { id: 'b', status: 'running', startedAt: '2026-09-28T08:00:00.000Z' },
268
+ ], NOW);
269
+ assert.equal(live.jobId, 'a');
270
+ });
@@ -16,7 +16,8 @@
16
16
  // 拆分自原 routes/workbench.js 814-955 行。
17
17
  //
18
18
  // 设计要点:
19
- // - 写盘在流式 chunk 阶段走 1.5s debounce;终态时(finally / cancel)强制 flush
19
+ // - 两条落盘路径:**jobs.json 只在终态写**;**运行中只写 live-jobs/<pid>.json**
20
+ // (1.5s 防抖)。后者是「别的 g ui 实例也能看到进行中」的依据,见 liveJobs.js
20
21
  // - 永不持久化 child 引用(参考 cancel 路由的浅拷贝模式)
21
22
  // - hydrate 时把 running/pending 降级为 error:原 child 进程已不存在
22
23
  // - enforceRetention 在每次落盘后跑,按 endedAt desc FIFO 裁剪
@@ -38,6 +39,13 @@ import {
38
39
  writeJson,
39
40
  nowIso,
40
41
  } from './shared.js';
42
+ import {
43
+ projectJob,
44
+ toLiveRecord,
45
+ writeLiveJobsFile,
46
+ removeLiveJobsFile,
47
+ scanLiveJobsDir,
48
+ } from './liveJobs.js';
41
49
 
42
50
  // 进程表:记录每个子任务的运行状态
43
51
  // jobId -> { id, taskId, subId, status, pid, startedAt, endedAt, exitCode, error, prompt, child, output, thinking, claudeSessionId, toolCalls }
@@ -59,12 +67,12 @@ export const cancelledJobs = new Set();
59
67
  // 的 deriveTaskColumn),而数据其实一直在 jobs.json 里,只是没第二个进程去读它。
60
68
  //
61
69
  // 现在:凡是需要"反映别的 g ui 进程"的读取路径,先 await refreshJobsFromDisk(),
62
- // 再用 mergedJobs() 拿「磁盘 ∪ 内存」。**内存优先** —— 本进程跑的 job 带着最新的
63
- // 流式输出和终态,磁盘上的那一份还可能停在 1.5s debounce 之前的旧状态。
70
+ // 再用 mergedJobs() 拿「内存 ∪ 运行中 ∪ 磁盘」。**内存优先** —— 本进程跑的 job 带着
71
+ // 最新的流式输出和终态,磁盘上的那一份还可能停在 1.5s debounce 之前的旧状态。
64
72
  //
65
73
  // 同步 vs 异步:refreshJobsFromDisk() 是异步的(要 stat + 读文件),mergedJobs() /
66
- // snapshotJobs() 保持同步纯函数,读缓存的磁盘那一半 —— 调用点"先 await 刷新、
67
- // 再同步取快照"这个顺序别颠倒,否则拿到的是上一次的磁盘内容。
74
+ // snapshotJobs() 保持同步纯函数,读缓存的两半 —— 调用点"先 await 刷新、再同步取快照"
75
+ // 这个顺序别颠倒,否则拿到的是上一次的磁盘内容。
68
76
  //
69
77
  // 用户明确接受秒级延迟:看板本身 5s 轮询,不需要为跨实例推送再造一套 IPC/SSE。
70
78
  //
@@ -72,6 +80,11 @@ export const cancelledJobs = new Set();
72
80
  // 没变就是一个 stat。
73
81
  let diskCache = { mtimeMs: -1, size: -1, jobs: [] };
74
82
 
83
+ // 「运行中」那一半:别的 g ui 进程写的 live-jobs/<pid>.json(见 liveJobs.js)。
84
+ // 这一半**只有 running/pending 的记录** —— 终态仍然只走 jobs.json,不存在两份口径。
85
+ // 同样用 signature(mtime+size 拼串)做短路。
86
+ let liveCache = { signature: null, jobs: [] };
87
+
75
88
  /** 磁盘记录自称 running/pending,但进程已消失、文件也已 N 秒没被写过 → 判为孤儿 */
76
89
  // 宽限期要大于 jobs 落盘的 1.5s debounce:job 刚结束时 child 已退出、终态还没 flush,
77
90
  // 那个窗口里(pid 死 + 磁盘 running)不该被误判。5s 足够跨过它,又快到用户感知不到。
@@ -104,11 +117,25 @@ function reclaimOrphan(j, fileMtimeMs) {
104
117
  };
105
118
  }
106
119
 
120
+ /**
121
+ * 把 live-jobs/ 目录当前内容读进缓存(别的 g ui 进程正在跑的 job)。
122
+ * 目录整体没变(signature 一样)就直接返回 —— 与 jobs.json 的 mtime+size 短路同理。
123
+ */
124
+ export async function refreshLiveJobs({ force = false } = {}) {
125
+ try {
126
+ const { signature, jobs: live } = await scanLiveJobsDir();
127
+ if (!force && signature === liveCache.signature) return;
128
+ liveCache = { signature, jobs: live };
129
+ } catch (err) {
130
+ logger.warn('[workbench] live-jobs 目录扫描失败,沿用上次缓存:', err?.message || err);
131
+ }
132
+ }
133
+
107
134
  /**
108
135
  * 把 jobs.json 当前内容读进缓存。没变( mtime + size 都没动)就直接返回。
109
136
  * 读失败一律降级成"沿用上次缓存":看板请求不该因为一个坏文件而 500。
110
137
  */
111
- export async function refreshJobsFromDisk({ force = false } = {}) {
138
+ async function refreshJobsFile({ force = false } = {}) {
112
139
  let st;
113
140
  try {
114
141
  st = await fsp.stat(JOBS_FILE);
@@ -134,29 +161,108 @@ export async function refreshJobsFromDisk({ force = false } = {}) {
134
161
  }
135
162
  }
136
163
 
164
+ /**
165
+ * 刷新两半缓存:history(jobs.json) + live(live-jobs/)。
166
+ *
167
+ * **所有"要反映别的实例"的读取路径都走这里**,所以 live 的刷新被折进来而不是
168
+ * 另开一个函数让调用点各自记得调 —— 忘了调不会报错,只会让「进行中」又不同步,
169
+ * 那正是这个 bug 的形态。jobs.json 那边的 mtime 短路不影响这里:函数入口先把
170
+ * 两半都刷一遍,各自内部再短路。
171
+ */
172
+ export async function refreshJobsFromDisk({ force = false } = {}) {
173
+ await refreshLiveJobs({ force });
174
+ await refreshJobsFile({ force });
175
+ }
176
+
137
177
  /** 本进程自己写完盘后把缓存作废,免得下一次 refresh 被 mtime 短路在旧内容上 */
138
178
  function invalidateDiskCache() {
139
179
  diskCache = { mtimeMs: -1, size: -1, jobs: [] };
180
+ liveCache = { signature: null, jobs: [] };
140
181
  }
141
182
 
142
183
  /**
143
- * 磁盘 ∪ 内存的 job 表(id -> 记录)。内存优先。
144
- * 同步、无 IO —— 磁盘那一半由 refreshJobsFromDisk() 预先填好。
184
+ * 「内存 ∪ 运行中(别的实例) ∪ 磁盘」的 job 表(id -> 记录)。
185
+ * 优先级:内存 > live > 磁盘 —— 内存是本进程跑的(最新),live 是别人的**在跑**记录
186
+ * (可能比它自己 jobs.json 里的旧终态新),磁盘是历史档案。
187
+ * 同步、无 IO —— 两半缓存由 refreshJobsFromDisk() 预先填好。
188
+ *
189
+ * includeLive=false 的用途:**写 jobs.json 时不要把别人的 live 记录写进去**。
190
+ * 那份是历史档案,而 live 记录的 output/thinking/toolCalls 是截尾的 —— 写进去等于
191
+ * 把一份残缺的日志永久归档,还会参与 size 计价的保留策略。别人的 job 由他自己落盘。
145
192
  */
146
- export function mergedJobs() {
193
+ export function mergedJobs({ includeLive = true } = {}) {
147
194
  const map = new Map();
148
195
  for (const j of diskCache.jobs) {
149
196
  if (!j || !j.id) continue;
150
197
  if (jobs.has(j.id)) continue; // 本进程跑的,下面用内存里的覆盖
151
198
  map.set(j.id, reclaimOrphan(j, diskCache.mtimeMs));
152
199
  }
200
+ // 别的实例正在跑的:覆盖同 id 的历史记录(那条历史多半是上一次跑的终态)
201
+ if (includeLive) {
202
+ for (const j of liveCache.jobs) {
203
+ if (!j || !j.id) continue;
204
+ if (jobs.has(j.id)) continue;
205
+ map.set(j.id, j);
206
+ }
207
+ }
153
208
  for (const [id, j] of jobs) map.set(id, j);
154
209
  return map;
155
210
  }
156
211
 
157
212
  // ── 持久化层 ─────────────────────────────────────────────────
158
- // jobs.json 是历史档案;jobs Map 只承载当前进程产出的活跃 job
159
- let jobsSaveTimer = null;
213
+ // 两条落盘路径,分工明确:
214
+ // · jobs.json(历史档案)—— **只在终态写**:一整份 7.5MB 的东西不该为了同步
215
+ // "还在跑"这件事被反复重写
216
+ // · live-jobs/<pid>.json(运行中)—— 1.5s 防抖写,几十 KB 封顶
217
+ // jobs Map 承载当前进程产出的活跃 job(也可能是刚结束、等终态 flush 的)。
218
+ //
219
+ // 历史档案没有防抖定时器了:它只在终态写(以前那个 `jobsSaveTimer`/`scheduleJobsSave`
220
+ // 是给"流式阶段也写 jobs.json"准备的,但从来没有任何调用点,2026-09-28 被 live-jobs 取代)。
221
+
222
+ // 运行中那一份的防抖写。由 taskRunner 在 job 转 running/pending 与流式 chunk 时调用。
223
+ let activeSaveTimer = null;
224
+ // 进程内写串行化:防抖回调与终态 flush 可能挨在一起,别让两次写交错
225
+ let activeWriteChain = Promise.resolve();
226
+
227
+ /** 本进程当前**活跃**的 job(running/pending),投影 + 瘦身后用于跨实例广播 */
228
+ function activeJobRecords() {
229
+ const list = [];
230
+ for (const j of jobs.values()) {
231
+ if (j && (j.status === 'running' || j.status === 'pending')) list.push(toLiveRecord(j));
232
+ }
233
+ return list;
234
+ }
235
+
236
+ /**
237
+ * 防抖写 live-jobs。**这是运行中 job 唯一的落盘点** —— 少了它,「进行中」就只在
238
+ * 跑任务的那个实例可见(2026-09-28 修的正是这个)。
239
+ */
240
+ export function scheduleActiveJobsSave() {
241
+ if (activeSaveTimer) return;
242
+ activeSaveTimer = setTimeout(() => {
243
+ activeSaveTimer = null;
244
+ flushActiveJobsSave().catch(err => logger.warn('[workbench] live-jobs save failed:', err?.message || err));
245
+ }, JOBS_SAVE_DEBOUNCE_MS);
246
+ }
247
+
248
+ /**
249
+ * 立即写 live-jobs:把本进程活跃 job 写进自己的文件,没有活跃 job 就删掉文件。
250
+ * 终态时(flushJobsSaveNow)必须走一次,否则别的实例的看板上那条会一直转圈,
251
+ * 直到 owner pid 消失才被回收。
252
+ */
253
+ export function flushActiveJobsSave() {
254
+ if (activeSaveTimer) { clearTimeout(activeSaveTimer); activeSaveTimer = null; }
255
+ const run = () => writeLiveJobsFile(process.pid, activeJobRecords());
256
+ const next = activeWriteChain.then(run, run);
257
+ activeWriteChain = next.catch(() => {});
258
+ return next;
259
+ }
260
+
261
+ /** 本进程退出时清掉自己的运行中文件(进程内其它清理路径无需感知这件事) */
262
+ export async function clearOwnLiveJobsFile() {
263
+ if (activeSaveTimer) { clearTimeout(activeSaveTimer); activeSaveTimer = null; }
264
+ await removeLiveJobsFile(process.pid);
265
+ }
160
266
 
161
267
  // 序列化 job 落盘:剥离 child 引用(ChildProcess 无法 JSON.stringify),
162
268
  // 反范式 taskTitle 方便管理页直接读
@@ -181,18 +287,8 @@ export function serializeJob(j, taskMap) {
181
287
  };
182
288
  }
183
289
 
184
- // 流式 chunk 阶段 debounce 写盘;终态时由 flushJobsSaveNow() 强制立即落盘
185
- export function scheduleJobsSave() {
186
- if (jobsSaveTimer) clearTimeout(jobsSaveTimer);
187
- jobsSaveTimer = setTimeout(() => {
188
- jobsSaveTimer = null;
189
- flushJobsSaveNow().catch(err => logger.warn('[workbench] jobs save failed:', err.message));
190
- }, JOBS_SAVE_DEBOUNCE_MS);
191
- }
192
-
193
290
  // 立即落盘(终态调用):把 jobs Map 当前快照写到 jobs.json,然后跑 retention
194
291
  export async function flushJobsSaveNow() {
195
- if (jobsSaveTimer) { clearTimeout(jobsSaveTimer); jobsSaveTimer = null; }
196
292
  // 读 tasks.json 给落盘 job 反范式 taskTitle——父任务被删后管理页仍可读
197
293
  const tasksData = await readJson(TASKS_FILE, { tasks: [] });
198
294
  const taskMap = new Map((tasksData.tasks || []).map(t => [t.id, t]));
@@ -201,9 +297,17 @@ export async function flushJobsSaveNow() {
201
297
  await refreshJobsFromDisk();
202
298
  const payload = {
203
299
  version: 1,
204
- jobs: Array.from(mergedJobs().values()).map(j => serializeJob(j, taskMap))
300
+ // 只并「磁盘历史 ∪ 本进程内存」:别人的 live(运行中)记录不进历史档案,见 mergedJobs 注释
301
+ jobs: Array.from(mergedJobs({ includeLive: false }).values()).map(j => serializeJob(j, taskMap))
205
302
  };
206
303
  await writeJson(JOBS_FILE, payload);
304
+ // 运行中那一份同步收口:这个 job 已经不再 running/pending,别的实例的看板
305
+ // 必须立刻把它从「进行中」挪走,不能干等 owner pid 消失。
306
+ try {
307
+ await flushActiveJobsSave();
308
+ } catch (err) {
309
+ logger.warn('[workbench] live-jobs flush failed:', err?.message || err);
310
+ }
207
311
  invalidateDiskCache();
208
312
  await enforceRetention();
209
313
  }
@@ -290,6 +394,12 @@ export async function enforceRetention() {
290
394
  invalidateDiskCache();
291
395
  const keepIds = new Set(data.jobs.map(j => j.id));
292
396
  for (const id of Array.from(jobs.keys())) {
397
+ const j = jobs.get(id);
398
+ // **运行中的 job 一律不许在这里被删掉**:它还没进过 jobs.json(或者进来的是上一轮的
399
+ // 终态),按 keepIds 一刀切会把它从内存里剜掉 —— 表现是任务跑到一半"消失",界面
400
+ // 也不再有它,而 child 进程还在。启动时 hydrateJobs 的 fire-and-forget 链路
401
+ // (读文件 → 跑 retention)刚好能和"启动后立刻点执行"撞上,实测能复现。
402
+ if (j && (j.status === 'running' || j.status === 'pending')) continue;
293
403
  if (!keepIds.has(id)) jobs.delete(id);
294
404
  }
295
405
  }
@@ -304,35 +414,15 @@ export function publish(event, payload) {
304
414
  bus.emit('event', { event, payload, ts: nowIso() });
305
415
  }
306
416
 
307
- // 给 SSE / 看板 / cancel / continue 路由用:返回「磁盘 ∪ 内存」的可序列化快照
417
+ // 给 SSE / 看板 / cancel / continue 路由用:返回「磁盘 ∪ 运行中 ∪ 内存」的可序列化快照
308
418
  // 剥离 child 引用(不可序列化),只保留前端需要的字段。
309
419
  //
310
- // 同步函数,磁盘那一半读的是 refreshJobsFromDisk() 填好的缓存 —— 想让别的 g ui
420
+ // 同步函数,两半缓存读的是 refreshJobsFromDisk() 填好的 —— 想让别的 g ui
311
421
  // 进程跑的 job 出现在结果里,调用前先 await 一次刷新(见文件头的说明)。
422
+ //
423
+ // 投影口径只有一份:liveJobs.projectJob(live-jobs 落盘也用同一个函数)。
312
424
  export function snapshotJobs() {
313
- return Array.from(mergedJobs().values()).map(j => ({
314
- id: j.id,
315
- taskId: j.taskId,
316
- subId: j.subId,
317
- title: j.title,
318
- status: j.status,
319
- prompt: j.prompt || '',
320
- output: j.output || '',
321
- thinking: j.thinking || '',
322
- pid: j.pid || null,
323
- startedAt: j.startedAt || null,
324
- endedAt: j.endedAt || null,
325
- exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
326
- error: j.error || null,
327
- // 本轮用的执行器(claude | opencode)。前端对话区助手名 / 日志详情按它显示;
328
- // 加字段时记得同步这里 —— 白名单投影会把没列出的字段静默剥掉。
329
- agent: j.agent || null,
330
- // 工具调用流水({id,name,argsPreview,arguments,result,status,error}[])。
331
- // 前端靠它画工具块;刷新页面 / 换实例后仍然可见,所以必须过白名单。
332
- toolCalls: Array.isArray(j.toolCalls) ? j.toolCalls : [],
333
- // 续接对话用:claude --output-format stream-json 的 system.init 事件捕获到的 session_id
334
- claudeSessionId: j.claudeSessionId || null
335
- }));
425
+ return Array.from(mergedJobs().values()).map(projectJob);
336
426
  }
337
427
 
338
428
  export const jobStore = {
@@ -340,7 +430,9 @@ export const jobStore = {
340
430
  bus,
341
431
  cancelledJobs,
342
432
  serializeJob,
343
- scheduleJobsSave,
433
+ scheduleActiveJobsSave,
434
+ flushActiveJobsSave,
435
+ clearOwnLiveJobsFile,
344
436
  flushJobsSaveNow,
345
437
  readJobsConfig,
346
438
  writeJobsConfig,
@@ -349,5 +441,6 @@ export const jobStore = {
349
441
  publish,
350
442
  snapshotJobs,
351
443
  refreshJobsFromDisk,
444
+ refreshLiveJobs,
352
445
  mergedJobs,
353
446
  };