zen-gitsync 2.17.21 → 2.17.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -12
- package/package.json +10 -1
- package/scripts/release.js +214 -82
- package/src/ui/public/assets/{AgentEngineSelector-D_8VedA0.js → AgentEngineSelector-DLJ8He83.js} +1 -1
- package/src/ui/public/assets/{AgentView-CpKIfQLq.js → AgentView-CQ18nw02.js} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-kyk4Mmvf.js → AppVersionBadge-1pyUzRer.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-BUPiKcdW.js → BranchSelector-DrsrZiWJ.js} +1 -1
- package/src/ui/public/assets/{CommitForm-CMe1gjY-.js → CommitForm-B5p-7P7m.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-DMWtstZq.js → CommonDialog-BEm2cK1X.js} +1 -1
- package/src/ui/public/assets/{EditorView-mUmFBb7H.css → EditorView-C6HFYeEM.css} +1 -1
- package/src/ui/public/assets/EditorView-fuMrS9P7.js +1 -0
- package/src/ui/public/assets/{FileDiffViewer-CvN5b1ZC.js → FileDiffViewer-CYxcLw10.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-Ynr4q8qE.js → FlowExecutionViewer-DTmlBLXQ.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-DPDZc9uf.js → FlowOrchestrationWorkspace-Da_UmTRT.js} +1 -1
- package/src/ui/public/assets/{IconButton-CFJkDLVG.js → IconButton-BShiC2AP.js} +1 -1
- package/src/ui/public/assets/{LogList-CbfsCVsy.js → LogList-5M5JBhlB.js} +1 -1
- package/src/ui/public/assets/{MindmapView-D8A1V-Fd.js → MindmapView-BUV_w14M.js} +1 -1
- package/src/ui/public/assets/{MonitorView-Bm_vUP8Y.js → MonitorView-D9N4QH5Y.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-BBND5XcZ.js → ProjectStartupButton-DYYQ-O7P.js} +1 -1
- package/src/ui/public/assets/RecentDirectoriesChat-BtWa_HMY.js +1 -0
- package/src/ui/public/assets/RecentDirectoriesChat-vHT8jMxW.css +1 -0
- package/src/ui/public/assets/{RemoteManagerDialog-BVQeIr8U.js → RemoteManagerDialog-C4QIJK_F.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-vuCu_cpA.js → RemoteRepoCard-BmDh538v.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-BZjgxQM8.js → SourceMapView-Ceamya-N.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-D0hK-p1U.js → SvgIcon-xtz4vvc9.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-XoPZv_i6.js → TemplateManager-DhKTZ4EM.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-CZarmsrv.js → UserInputNode-DfeCGlEO.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-D9l3i4Qj.css +1 -0
- package/src/ui/public/assets/WorkbenchView-DX2epPD9.js +6 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-N8Vc5eEs.js → _plugin-vue_export-helper-C_bQVzeb.js} +4 -4
- package/src/ui/public/assets/{agentConversations-Da7zLPfP.js → agentConversations-BUPj8vzH.js} +1 -1
- package/src/ui/public/assets/configStore-DJH7OlB_.js +1 -0
- package/src/ui/public/assets/{gitStore-grKwgyHH.js → gitStore-ByP9lpuT.js} +1 -1
- package/src/ui/public/assets/index-CFX8sK4w.js +29 -0
- package/src/ui/public/assets/{previewSandbox-D4455rKO.js → previewSandbox-DSzZ_Rky.js} +1 -1
- package/src/ui/public/index.html +10 -10
- package/src/ui/public/sounds/CREDITS.txt +32 -0
- package/src/ui/public/sounds/task-done.wav +0 -0
- package/src/ui/public/sounds/task-error.wav +0 -0
- package/src/ui/server/routes/config.js +4 -1
- package/src/ui/server/routes/workbench/index.js +15 -6
- package/src/ui/server/routes/workbench/jobActivity.js +274 -0
- package/src/ui/server/routes/workbench/jobActivity.test.js +295 -0
- package/src/ui/server/routes/workbench/liveJobs.js +3 -0
- package/src/ui/server/routes/workbench/orchestratorStore.js +53 -2
- package/src/ui/server/routes/workbench/orchestratorStore.progressReport.test.js +177 -0
- package/src/ui/server/routes/workbench/progressReport.js +54 -47
- package/src/ui/server/routes/workbench/progressReport.test.js +73 -42
- package/src/ui/server/routes/workbench/projectRegistry.js +36 -2
- package/src/ui/server/routes/workbench/projectRegistry.test.js +98 -0
- package/src/ui/server/routes/workbench/taskRunner.js +9 -0
- package/src/utils/selfUpdatePolicy.js +123 -0
- package/src/utils/selfUpdatePolicy.test.js +204 -0
- package/src/ui/public/assets/EditorView-C6msf9YP.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-BQbncY1L.css +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-D9fMFh8r.js +0 -1
- package/src/ui/public/assets/WorkbenchView-BLyXsdYw.css +0 -1
- package/src/ui/public/assets/WorkbenchView-CPur8WEj.js +0 -6
- package/src/ui/public/assets/configStore-BFb0I8eP.js +0 -1
- package/src/ui/public/assets/index-BHYAwndE.js +0 -29
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
jobAgent,
|
|
35
|
+
buildLiveActivity,
|
|
36
|
+
pickLiveActivity,
|
|
37
|
+
TOOL_MIX_WINDOW,
|
|
38
|
+
SILENT_NOTABLE_MS,
|
|
39
|
+
} from './jobActivity.js';
|
|
40
|
+
|
|
41
|
+
// ── tailLine ────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
test('tailLine 取最后一行有效文本,跳过末尾空行', () => {
|
|
44
|
+
assert.equal(tailLine('第一行\n第二行\n\n \n'), '第二行');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('tailLine 把行内空白折成一个空格(多行输出里的缩进不该原样进 prompt / 卡片)', () => {
|
|
48
|
+
assert.equal(tailLine('a\n 正在 编辑\t文件 '), '正在 编辑 文件');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('tailLine 截断超长行,且空 / 非字符串一律返回空串', () => {
|
|
52
|
+
assert.equal(tailLine('x'.repeat(500)).length, 160);
|
|
53
|
+
assert.equal(tailLine(''), '');
|
|
54
|
+
assert.equal(tailLine(null), '');
|
|
55
|
+
assert.equal(tailLine(undefined), '');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── tailExcerpt(跑完之后卡片上那段「最后说了什么」) ──────────────────────
|
|
59
|
+
// 这段摘录要回答的是"这条已完成的任务,是不是其实在等我回话",所以断言的重心是
|
|
60
|
+
// **结尾那句必须还在**:模型收尾时那句「要 push 吗?」被截掉的话,这个功能就白做了。
|
|
61
|
+
|
|
62
|
+
test('tailExcerpt 取尾部一段,压平成一行并去掉 markdown 标记', () => {
|
|
63
|
+
const out = '## 改动\n\n- **新增** `lastReply` 字段\n- 卡片上显示最后回复\n\n要 push 吗?';
|
|
64
|
+
// 标题 / 列表标记 / ** / 反引号都只是噪声;卡片上没有富文本,留着一堆符号没法读
|
|
65
|
+
assert.equal(tailExcerpt(out), '改动 新增 lastReply 字段 卡片上显示最后回复 要 push 吗?');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('tailExcerpt 丢掉表格行与分隔线(压成一行后只剩一堆竖线)', () => {
|
|
69
|
+
const out = '## 验证\n\n| 项 | 结果 |\n|---|---|\n| vue-tsc | 0 错误 |\n\n已提交。要 push 吗?';
|
|
70
|
+
assert.equal(tailExcerpt(out), '验证 已提交。要 push 吗?');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('tailExcerpt 真截断过才加 …(结尾那句永远保留)', () => {
|
|
74
|
+
const long = `${'前'.repeat(300)}收尾那句:要 push 吗?`;
|
|
75
|
+
const got = tailExcerpt(long);
|
|
76
|
+
assert.ok(got.startsWith('…'), '从中间开始就该有 … 标记');
|
|
77
|
+
assert.ok(got.endsWith('要 push 吗?'), '结尾那句不能被截掉 —— CSS 截的是尾巴,这是它被截的原因');
|
|
78
|
+
// 上限按"去掉 … 之后"算:… 不计入 100 字的预算
|
|
79
|
+
assert.equal(got.length, 101);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('tailExcerpt 没截断就不加 …(… 是"这句话从中间开始"的标记,不能乱标)', () => {
|
|
83
|
+
assert.equal(tailExcerpt('你好!有什么可以帮你的?'), '你好!有什么可以帮你的?');
|
|
84
|
+
// 短输出连第一行一起保留:窗口没切,第一行是完整的一句,不是半截
|
|
85
|
+
assert.equal(tailExcerpt('你好!\n\n需要的话我可以读本机的看板文件。'), '你好! 需要的话我可以读本机的看板文件。');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('tailExcerpt 窗口起点落在半句中间时从下一个整行开始(摘录不以半句开头)', () => {
|
|
89
|
+
const out = `${'前'.repeat(5000)}\n完整的一行\n要 push 吗?`;
|
|
90
|
+
// 5000 字 > REPLY_WINDOW_CHARS:被切掉的首行本就是半句,丢掉而不是拼进来
|
|
91
|
+
assert.equal(tailExcerpt(out), '完整的一行 要 push 吗?');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('tailExcerpt 单行超长输出也能给出尾部(窗口里没有换行可退)', () => {
|
|
95
|
+
const got = tailExcerpt(`${'字'.repeat(5000)}要 push 吗?`);
|
|
96
|
+
assert.ok(got.startsWith('…'));
|
|
97
|
+
assert.ok(got.endsWith('要 push 吗?'));
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('tailExcerpt 空输出 / 脏值 / 全是表格噪声时给空串(前端据此不渲染这一段)', () => {
|
|
101
|
+
assert.equal(tailExcerpt(''), '');
|
|
102
|
+
assert.equal(tailExcerpt(null), '');
|
|
103
|
+
assert.equal(tailExcerpt(undefined), '');
|
|
104
|
+
assert.equal(tailExcerpt(' \n\n '), '');
|
|
105
|
+
assert.equal(tailExcerpt('|---|---|\n| a | b |'), '');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ── describeLastTool ────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
test('describeLastTool 取最后一次带名字的调用,优先用 argsPreview', () => {
|
|
111
|
+
const calls = [
|
|
112
|
+
{ name: 'Read', argsPreview: 'a.ts', arguments: '{"file_path":"a.ts"}' },
|
|
113
|
+
{ name: 'Edit', argsPreview: 'b.ts', arguments: '{"file_path":"b.ts"}' },
|
|
114
|
+
];
|
|
115
|
+
assert.equal(describeLastTool(calls), 'Edit b.ts');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('describeLastTool 跳过没有名字的尾部记录(老数据 / 半截记录)', () => {
|
|
119
|
+
const calls = [{ name: 'Bash', argsPreview: 'npm test' }, { id: 'anon-1' }];
|
|
120
|
+
assert.equal(describeLastTool(calls), 'Bash npm test');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('describeLastTool 没有 argsPreview 时退回 arguments;两者都没有就只给工具名', () => {
|
|
124
|
+
assert.equal(describeLastTool([{ name: 'Bash', arguments: 'ls -la' }]), 'Bash ls -la');
|
|
125
|
+
assert.equal(describeLastTool([{ name: 'Bash' }]), 'Bash');
|
|
126
|
+
assert.equal(describeLastTool([]), '');
|
|
127
|
+
assert.equal(describeLastTool(null), '');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── describeToolMix / silentMsOf ────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
test('describeToolMix 给出最近几次调用的分布,多的在前', () => {
|
|
133
|
+
const calls = [
|
|
134
|
+
{ name: 'Bash' }, { name: 'Read' }, { name: 'Bash' },
|
|
135
|
+
{ name: 'Read' }, { name: 'Bash' }, { name: 'Edit' },
|
|
136
|
+
];
|
|
137
|
+
assert.equal(describeToolMix(calls), 'Bash×3 · Read×2 · Edit');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('describeToolMix 只看最近 TOOL_MIX_WINDOW 次 —— 早先的模式早被淹没了', () => {
|
|
141
|
+
// 先 30 次 Edit,再 20 次 Read:窗口内只有 Read,分布里不该冒出 Edit
|
|
142
|
+
const calls = [
|
|
143
|
+
...Array.from({ length: 30 }, () => ({ name: 'Edit' })),
|
|
144
|
+
...Array.from({ length: TOOL_MIX_WINDOW }, () => ({ name: 'Read' })),
|
|
145
|
+
];
|
|
146
|
+
assert.equal(describeToolMix(calls), `Read×${TOOL_MIX_WINDOW}`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('describeToolMix 跳过没有名字的记录,但它们不占窗口名额', () => {
|
|
150
|
+
const calls = [
|
|
151
|
+
{ name: 'Bash' },
|
|
152
|
+
...Array.from({ length: TOOL_MIX_WINDOW }, () => ({ id: 'anon-1' })),
|
|
153
|
+
];
|
|
154
|
+
assert.equal(describeToolMix(calls), 'Bash');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('describeToolMix 没有调用时给空串(调用方据此不显示这一行)', () => {
|
|
158
|
+
assert.equal(describeToolMix([]), '');
|
|
159
|
+
assert.equal(describeToolMix(null), '');
|
|
160
|
+
assert.equal(describeToolMix(undefined), '');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('silentMsOf 到阈值才算静默,不到 / 没这个字段一律 null', () => {
|
|
164
|
+
const at = '2026-09-28T10:00:00.000Z';
|
|
165
|
+
const base = Date.parse(at);
|
|
166
|
+
const job = { lastActivityAt: at };
|
|
167
|
+
assert.equal(silentMsOf(job, base + SILENT_NOTABLE_MS), SILENT_NOTABLE_MS);
|
|
168
|
+
assert.equal(silentMsOf(job, base + SILENT_NOTABLE_MS - 1), null);
|
|
169
|
+
// 老记录 / 别的实例上的旧版进程没有这个字段:给 null,而不是编一个 0
|
|
170
|
+
assert.equal(silentMsOf({}, base), null);
|
|
171
|
+
assert.equal(silentMsOf({ lastActivityAt: '不是时间' }, base), null);
|
|
172
|
+
assert.equal(silentMsOf(null, base), null);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ── jobAgent(看板卡片上那个执行器图标该画谁) ────────────────────────────
|
|
176
|
+
// 认不出时必须回空串,不能回落成某个品牌:卡片上画一个错的执行器标,
|
|
177
|
+
// 用户会以为这条是 Claude 跑的,而它可能是 Codex —— 这比不画图标糟得多。
|
|
178
|
+
|
|
179
|
+
test('jobAgent:认识的执行器原样返回', () => {
|
|
180
|
+
assert.equal(jobAgent({ agent: 'claude' }), 'claude');
|
|
181
|
+
assert.equal(jobAgent({ agent: 'opencode' }), 'opencode');
|
|
182
|
+
assert.equal(jobAgent({ agent: 'codex' }), 'codex');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('jobAgent:大小写与首尾空白归一后再认(jobs.json 会被手工编辑)', () => {
|
|
186
|
+
assert.equal(jobAgent({ agent: ' Codex ' }), 'codex');
|
|
187
|
+
assert.equal(jobAgent({ agent: 'CLAUDE' }), 'claude');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('jobAgent:认不出的一律空串,不猜', () => {
|
|
191
|
+
assert.equal(jobAgent({ agent: 'gemini' }), '');
|
|
192
|
+
assert.equal(jobAgent({ agent: '' }), '');
|
|
193
|
+
assert.equal(jobAgent({}), ''); // 老记录没有 agent 字段
|
|
194
|
+
assert.equal(jobAgent({ agent: 123 }), '');
|
|
195
|
+
assert.equal(jobAgent(null), '');
|
|
196
|
+
assert.equal(jobAgent(undefined), '');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── buildLiveActivity ───────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const START = '2026-09-28T10:00:00.000Z';
|
|
202
|
+
const NOW = Date.parse('2026-09-28T10:03:20.000Z'); // 距 START 3 分 20 秒
|
|
203
|
+
|
|
204
|
+
/** 一条"跑了 3 分 20 秒"的 job 模板 */
|
|
205
|
+
function liveJob(over = {}) {
|
|
206
|
+
return {
|
|
207
|
+
id: 'j1',
|
|
208
|
+
taskId: 't1',
|
|
209
|
+
status: 'running',
|
|
210
|
+
agent: 'claude',
|
|
211
|
+
startedAt: START,
|
|
212
|
+
thinking: '先看现状\n这个文件的锁逻辑有问题,得先弄清楚谁在什么时候写',
|
|
213
|
+
output: '我先读一下 login.ts',
|
|
214
|
+
toolCalls: [{ name: 'Read', argsPreview: 'src/login.ts' }],
|
|
215
|
+
...over,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
test('buildLiveActivity:终态 / 空 job 返回 null(卡片据此不显示活动区)', () => {
|
|
220
|
+
assert.equal(buildLiveActivity(liveJob({ status: 'done' }), NOW), null);
|
|
221
|
+
assert.equal(buildLiveActivity(liveJob({ status: 'error' }), NOW), null);
|
|
222
|
+
assert.equal(buildLiveActivity(null, NOW), null);
|
|
223
|
+
// pending 也算在跑(进程刚起来还没吐字):卡片上仍要给"已运行 0 秒",不能空着
|
|
224
|
+
assert.equal(buildLiveActivity(liveJob({ status: 'pending' }), NOW).status, 'pending');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('buildLiveActivity:把思考 / 工具 / 最新回复都抽成一句话,时长按传入的 now 算', () => {
|
|
228
|
+
const live = buildLiveActivity(liveJob(), NOW);
|
|
229
|
+
assert.equal(live.elapsedMs, 3 * 60 * 1000 + 20 * 1000);
|
|
230
|
+
assert.equal(live.agent, 'claude');
|
|
231
|
+
assert.equal(live.startedAt, START);
|
|
232
|
+
assert.equal(live.toolCallCount, 1);
|
|
233
|
+
assert.equal(live.lastTool, 'Read src/login.ts');
|
|
234
|
+
assert.equal(live.toolMix, 'Read');
|
|
235
|
+
assert.equal(live.lastThought, '这个文件的锁逻辑有问题,得先弄清楚谁在什么时候写');
|
|
236
|
+
assert.equal(live.lastLine, '我先读一下 login.ts');
|
|
237
|
+
// 没有 lastActivityAt → 静默这条不成立,给 null 而不是编个 0
|
|
238
|
+
assert.equal(live.silentMs, null);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('buildLiveActivity:老记录没有 agent / toolCalls 时给出空值,不抛错', () => {
|
|
242
|
+
const live = buildLiveActivity({ id: 'j2', status: 'running', startedAt: START }, NOW);
|
|
243
|
+
assert.equal(live.agent, '');
|
|
244
|
+
assert.equal(live.toolCallCount, 0);
|
|
245
|
+
assert.equal(live.lastTool, '');
|
|
246
|
+
assert.equal(live.lastThought, '');
|
|
247
|
+
assert.equal(live.lastLine, '');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test('buildLiveActivity:startedAt 缺失或脏值时 elapsedMs 记 0(不显示负数 / NaN)', () => {
|
|
251
|
+
assert.equal(buildLiveActivity({ id: 'j', status: 'running' }, NOW).elapsedMs, 0);
|
|
252
|
+
assert.equal(buildLiveActivity({ id: 'j', status: 'running', startedAt: '不是时间' }, NOW).elapsedMs, 0);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ── pickLiveActivity ────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
test('pickLiveActivity:一个任务有多条 job 时挑最近有动静的那条,不是跑得最久的', () => {
|
|
258
|
+
const live = pickLiveActivity([
|
|
259
|
+
{ id: 'old', status: 'running', startedAt: '2026-09-28T09:00:00.000Z', output: '老的那条' },
|
|
260
|
+
{
|
|
261
|
+
id: 'fresh',
|
|
262
|
+
status: 'running',
|
|
263
|
+
startedAt: '2026-09-28T10:00:00.000Z',
|
|
264
|
+
lastActivityAt: '2026-09-28T10:03:00.000Z',
|
|
265
|
+
output: '刚吐过字的那条',
|
|
266
|
+
},
|
|
267
|
+
], NOW);
|
|
268
|
+
assert.equal(live.jobId, 'fresh');
|
|
269
|
+
assert.equal(live.lastLine, '刚吐过字的那条');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('pickLiveActivity:只看在跑的 —— 有终态 job 时不拿它顶替', () => {
|
|
273
|
+
const done = { id: 'done', status: 'done', startedAt: '2026-09-28T10:02:00.000Z', output: '已经结束了' };
|
|
274
|
+
assert.equal(pickLiveActivity([done], NOW), null);
|
|
275
|
+
const running = { id: 'run', status: 'running', startedAt: START, output: '还在跑' };
|
|
276
|
+
assert.equal(pickLiveActivity([done, running], NOW).jobId, 'run');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('pickLiveActivity:时间戳打平时取末条(与 latestJob 同口径),空数组给 null', () => {
|
|
280
|
+
const same = [
|
|
281
|
+
{ id: 'a', status: 'running', startedAt: START, output: 'a' },
|
|
282
|
+
{ id: 'b', status: 'running', startedAt: START, output: 'b' },
|
|
283
|
+
];
|
|
284
|
+
assert.equal(pickLiveActivity(same, NOW).jobId, 'b');
|
|
285
|
+
assert.equal(pickLiveActivity([], NOW), null);
|
|
286
|
+
assert.equal(pickLiveActivity(null, NOW), null);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('pickLiveActivity:lastActivityAt 是脏值时退回 startedAt 比较', () => {
|
|
290
|
+
const live = pickLiveActivity([
|
|
291
|
+
{ id: 'a', status: 'running', startedAt: '2026-09-28T09:00:00.000Z', lastActivityAt: '不是时间' },
|
|
292
|
+
{ id: 'b', status: 'running', startedAt: '2026-09-28T08:00:00.000Z' },
|
|
293
|
+
], NOW);
|
|
294
|
+
assert.equal(live.jobId, 'a');
|
|
295
|
+
});
|
|
@@ -88,6 +88,9 @@ export function projectJob(j) {
|
|
|
88
88
|
thinking: j.thinking || '',
|
|
89
89
|
pid: j.pid || null,
|
|
90
90
|
startedAt: j.startedAt || null,
|
|
91
|
+
// 最后一次产出的时刻。别的实例跑的任务,进度报告只能从这份文件上读它 ——
|
|
92
|
+
// 不过白名单就会变成"本实例的报告说得出静默多久,跨实例的报告永远说不出来"。
|
|
93
|
+
lastActivityAt: j.lastActivityAt || null,
|
|
91
94
|
endedAt: j.endedAt || null,
|
|
92
95
|
exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
|
|
93
96
|
error: j.error || null,
|
|
@@ -344,25 +344,76 @@ function normalizeReport(raw) {
|
|
|
344
344
|
toolCallCount: Number.isFinite(Number(t.toolCallCount)) ? Math.max(0, Math.floor(Number(t.toolCallCount))) : 0,
|
|
345
345
|
lastTool: typeof t.lastTool === 'string' ? t.lastTool : '',
|
|
346
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,
|
|
347
352
|
})),
|
|
348
353
|
};
|
|
349
354
|
}
|
|
350
355
|
|
|
351
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* 读报告历史(新的在前)。文件缺失 / 损坏一律当"还没有报告",不抛错。
|
|
358
|
+
*
|
|
359
|
+
* **没有事实的报告不算报告**:那是一条"当时什么都没在跑"的空记录,对回头翻历史的人
|
|
360
|
+
* 零信息量,却要占掉上限 20 条里的一格,把真正有用的挤出去(用户 2026-09-29 的原话:
|
|
361
|
+
* "这种没有在执行的就不用展示了")。
|
|
362
|
+
*
|
|
363
|
+
* 写入侧已经不再产生它们(见 routes/workbench/index.js 的 runProgressReport),
|
|
364
|
+
* 这里再挡一次是因为:老版本进程 / 另一个实例写进同一份文件的空报告,
|
|
365
|
+
* 在读取方看来同样得当成不存在 —— 面板只认这个函数给的清单。
|
|
366
|
+
* 另外 appendReport 是拿本函数的返回值重写整个文件的,所以历史遗留的空报告
|
|
367
|
+
* 会在下一次落盘时被真正从磁盘上清掉。
|
|
368
|
+
*/
|
|
352
369
|
export async function readReports() {
|
|
353
370
|
const data = await readJson(ORCHESTRATOR_REPORTS_FILE, null);
|
|
354
371
|
const list = data && Array.isArray(data.reports) ? data.reports : [];
|
|
355
372
|
return list
|
|
356
373
|
.map(normalizeReport)
|
|
374
|
+
.filter(r => r.tasks.length > 0)
|
|
357
375
|
.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
|
|
358
376
|
.slice(0, MAX_PROGRESS_REPORTS);
|
|
359
377
|
}
|
|
360
378
|
|
|
361
|
-
/**
|
|
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>} 落盘的那一份;被判为重复时返回已在历史里的头部那条 */
|
|
362
404
|
export async function appendReport(entry) {
|
|
363
405
|
return serialize(async () => {
|
|
364
406
|
const report = normalizeReport({ id: genId(), at: nowIso(), ...entry });
|
|
365
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
|
+
}
|
|
366
417
|
const reports = [report, ...existing].slice(0, MAX_PROGRESS_REPORTS);
|
|
367
418
|
await writeJson(ORCHESTRATOR_REPORTS_FILE, { version: 1, reports });
|
|
368
419
|
return report;
|
|
@@ -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
|
+
});
|