zen-gitsync 2.16.49 → 2.16.51

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 (66) hide show
  1. package/README.md +8 -6
  2. package/package.json +5 -2
  3. package/src/ui/public/assets/AgentView-DcTGw8-j.js +4 -0
  4. package/src/ui/public/assets/AppVersionBadge-CA5-POGA.js +2 -0
  5. package/src/ui/public/assets/BranchSelector-qn-iGHOI.js +1 -0
  6. package/src/ui/public/assets/CommitForm-DHfyJ331.js +6 -0
  7. package/src/ui/public/assets/CommonDialog-DJBixJcI.js +1 -0
  8. package/src/ui/public/assets/EditorView-BRWoa7--.js +1 -0
  9. package/src/ui/public/assets/{FlowExecutionViewer-CuID17Kg.js → FlowExecutionViewer-YoPgld-R.js} +1 -1
  10. package/src/ui/public/assets/FlowOrchestrationWorkspace-DyEjlCBW.js +21 -0
  11. package/src/ui/public/assets/LogList-_z4dIgvS.js +7 -0
  12. package/src/ui/public/assets/MindmapView-BrkcAcDh.js +2 -0
  13. package/src/ui/public/assets/MonitorView-8TG_K4m3.js +1 -0
  14. package/src/ui/public/assets/ProjectStartupButton-BqLyrik7.js +1 -0
  15. package/src/ui/public/assets/RemoteManagerDialog-LOVV6KrB.js +4 -0
  16. package/src/ui/public/assets/RemoteRepoCard-SQyKcXDG.js +7 -0
  17. package/src/ui/public/assets/SourceMapView-DckLynaL.js +3 -0
  18. package/src/ui/public/assets/{SvgIcon-koi3MuB8.js → SvgIcon-CvIX1KL7.js} +1 -1
  19. package/src/ui/public/assets/UserInputNode-Bc124-BK.js +1 -0
  20. package/src/ui/public/assets/WorkbenchView-BtQkd9Oy.js +37 -0
  21. package/src/ui/public/assets/WorkbenchView-FdhmMI-b.css +1 -0
  22. package/src/ui/public/assets/{_plugin-vue_export-helper-VkarRGOe.js → _plugin-vue_export-helper-N7dqbp6x.js} +5 -5
  23. package/src/ui/public/assets/configStore-NaVpBxPo.js +1 -0
  24. package/src/ui/public/assets/{element-plus-C3mePHEv.js → element-plus-Csu3CJKj.js} +3 -3
  25. package/src/ui/public/assets/{flow-mindmap-BzX-PWap.js → flow-mindmap-wqtwJcDW.js} +12 -12
  26. package/src/ui/public/assets/index-Ci3lbjk2.css +1 -0
  27. package/src/ui/public/assets/{index-Cn4CA40E.js → index-D9u0mCSi.js} +49 -48
  28. package/src/ui/public/assets/{office-docx-pY9m7PMg.js → office-docx-BKrAsewH.js} +6 -6
  29. package/src/ui/public/assets/{office-excel-F21cTf8V.js → office-excel-5lGzd5Yy.js} +1 -1
  30. package/src/ui/public/assets/{office-pptx-XHr0prIp.js → office-pptx-DKtkU_7r.js} +1 -1
  31. package/src/ui/public/assets/toolsStore-CNDLLgUG.js +1 -0
  32. package/src/ui/public/assets/{vendor-EBzJAuo1.js → vendor-C6ntdXkR.js} +43 -43
  33. package/src/ui/public/assets/{vendor-BkgN6wPK.css → vendor-_6pgW9Z2.css} +1 -1
  34. package/src/ui/public/assets/vue-flow-D7IxiTyP.js +39 -0
  35. package/src/ui/public/index.html +12 -12
  36. package/src/ui/server/routes/workbench/index.js +206 -5
  37. package/src/ui/server/routes/workbench/orchestratorStore.js +260 -0
  38. package/src/ui/server/routes/workbench/projectRegistry.js +385 -0
  39. package/src/ui/server/routes/workbench/projectRegistry.test.js +339 -0
  40. package/src/ui/server/routes/workbench/promptParts.js +51 -0
  41. package/src/ui/server/routes/workbench/promptParts.test.js +82 -0
  42. package/src/ui/server/routes/workbench/shared.js +5 -0
  43. package/src/ui/server/routes/workbench/taskRunner.js +3 -2
  44. package/src/ui/server/utils/directoryGitState.js +92 -7
  45. package/src/ui/server/utils/directoryGitState.test.js +135 -2
  46. package/src/ui/public/assets/AgentView-CZhZ-RtZ.js +0 -4
  47. package/src/ui/public/assets/AppVersionBadge-CGLBfOhF.js +0 -2
  48. package/src/ui/public/assets/BranchSelector-DO0xkvYF.js +0 -1
  49. package/src/ui/public/assets/CommitForm-ickhPe9o.js +0 -6
  50. package/src/ui/public/assets/CommonDialog-CliNZp_i.js +0 -1
  51. package/src/ui/public/assets/EditorView-Bj5Buc8r.js +0 -1
  52. package/src/ui/public/assets/FlowOrchestrationWorkspace-BWynSUhQ.js +0 -21
  53. package/src/ui/public/assets/LogList-C8fYaGlz.js +0 -7
  54. package/src/ui/public/assets/MindmapView-Co2ys9e3.js +0 -2
  55. package/src/ui/public/assets/MonitorView-BxFwNYjB.js +0 -1
  56. package/src/ui/public/assets/ProjectStartupButton-DKspkGJC.js +0 -1
  57. package/src/ui/public/assets/RemoteManagerDialog-CRq596ya.js +0 -4
  58. package/src/ui/public/assets/RemoteRepoCard-9_Y3iDT6.js +0 -7
  59. package/src/ui/public/assets/SourceMapView-CFv9JUGZ.js +0 -3
  60. package/src/ui/public/assets/UserInputNode-CUTCIcEc.js +0 -1
  61. package/src/ui/public/assets/WorkbenchView-Bp2YaSGS.css +0 -1
  62. package/src/ui/public/assets/WorkbenchView-COoRqTST.js +0 -28
  63. package/src/ui/public/assets/configStore-vksEBmTc.js +0 -1
  64. package/src/ui/public/assets/index-C3zoLat6.css +0 -1
  65. package/src/ui/public/assets/toolsStore-CHBnAWFM.js +0 -1
  66. package/src/ui/public/assets/vue-flow-DB6byJul.js +0 -39
@@ -0,0 +1,339 @@
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
+ // 这些规则前后端只实现一次(后端),所以口径漂移的代价全压在这里的断言上。
17
+
18
+ import { test } from 'node:test';
19
+ import assert from 'node:assert/strict';
20
+
21
+ import {
22
+ canonicalProjectPath,
23
+ projectName,
24
+ resolveTaskRepoPath,
25
+ deriveTaskColumn,
26
+ latestJob,
27
+ summarizeProjectTasks,
28
+ buildProjectEntries,
29
+ decorateTaskForBoard,
30
+ OUTPUT_TAIL_CHARS,
31
+ trimJobForDetail,
32
+ buildTaskDetail,
33
+ } from './projectRegistry.js';
34
+ import { normalizeOrchestrator, buildActivityFeed, buildRunningAgents } from './orchestratorStore.js';
35
+
36
+ const sub = (id, status) => ({ id, title: id, desc: '', status, promptOverride: '' });
37
+ const job = (id, taskId, status, startedAt, endedAt = null) => ({
38
+ id, taskId, subId: `${taskId}__${id}`, title: '', status, startedAt, endedAt,
39
+ });
40
+
41
+ // ── 路径归一 ────────────────────────────────────────────────────────
42
+
43
+ test('canonicalProjectPath: 盘符转大写 + Windows 斜杠归一(与前端同口径)', () => {
44
+ assert.equal(canonicalProjectPath('e:\\workspace\\x'), 'E:\\workspace\\x');
45
+ assert.equal(canonicalProjectPath(' D:/ws/y '), 'D:\\ws\\y');
46
+ // 同一目录的两种斜杠写法必须落到同一个 key,否则项目列表里出现重复项
47
+ // (实测 article-generator 就同时存在于 D:/... 与 D:\... 两种常用目录里)
48
+ assert.equal(canonicalProjectPath('D:/ws/y'), canonicalProjectPath('D:\\ws\\y'));
49
+ // POSIX 路径原样返回,不能被改写成反斜杠
50
+ assert.equal(canonicalProjectPath('/home/me/proj'), '/home/me/proj');
51
+ assert.equal(canonicalProjectPath('/home/me/proj/nested'), '/home/me/proj/nested');
52
+ assert.equal(canonicalProjectPath(''), '');
53
+ assert.equal(canonicalProjectPath(null), '');
54
+ });
55
+
56
+ test('projectName 取末级目录名,空路径返回空串', () => {
57
+ assert.equal(projectName('D:\\ws\\zen-gitsync'), 'zen-gitsync');
58
+ assert.equal(projectName('/home/me/proj/'), 'proj');
59
+ assert.equal(projectName(''), '');
60
+ });
61
+
62
+ // ── 执行目录 ────────────────────────────────────────────────────────
63
+
64
+ test('resolveTaskRepoPath 优先用任务自己的项目,空则回退当前项目', () => {
65
+ assert.equal(resolveTaskRepoPath({ projectPath: 'D:\\proj-a' }, 'D:\\proj-b'), 'D:\\proj-a');
66
+ assert.equal(resolveTaskRepoPath({ projectPath: ' ' }, 'D:\\proj-b'), 'D:\\proj-b');
67
+ assert.equal(resolveTaskRepoPath({}, 'D:\\proj-b'), 'D:\\proj-b');
68
+ // 目录不存在也不回退:宁可执行报错,也不能把改动悄悄落到另一个仓库
69
+ assert.equal(resolveTaskRepoPath({ projectPath: 'Z:\\gone' }, 'D:\\proj-b'), 'Z:\\gone');
70
+ });
71
+
72
+ // ── 看板列推导 ──────────────────────────────────────────────────────
73
+
74
+ test('deriveTaskColumn: 从没动过的任务在待处理', () => {
75
+ assert.equal(deriveTaskColumn({ subtasks: [] }, []), 'todo');
76
+ assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'todo')] }, []), 'todo');
77
+ });
78
+
79
+ test('deriveTaskColumn: 有 job 在跑或子任务 running 就是进行中', () => {
80
+ assert.equal(deriveTaskColumn({ subtasks: [] }, [job('j1', 't1', 'running', '2026-01-01T00:00:00Z')]), 'doing');
81
+ assert.equal(deriveTaskColumn({ subtasks: [] }, [job('j1', 't1', 'pending', '2026-01-01T00:00:00Z')]), 'doing');
82
+ assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'running')] }, []), 'doing');
83
+ });
84
+
85
+ test('deriveTaskColumn: 子任务全部完成才算已完成', () => {
86
+ assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'done'), sub('s2', 'done')] }, []), 'done');
87
+ // 一半完成 → 跑过但没收尾,落在评审中
88
+ assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'done'), sub('s2', 'todo')] }, []), 'review');
89
+ // 有子任务报错也算"需要人看一眼"
90
+ assert.equal(deriveTaskColumn({ subtasks: [sub('s1', 'error'), sub('s2', 'todo')] }, []), 'review');
91
+ });
92
+
93
+ test('deriveTaskColumn: 无子任务的任务只能看执行记录', () => {
94
+ assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'done', '2026-01-01T00:00:00Z')]), 'done');
95
+ assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'cancelled', '2026-01-01T00:00:00Z')]), 'review');
96
+ assert.equal(deriveTaskColumn({ subtasks: [], type: 'simple' }, [job('j1', 't1', 'error', '2026-01-01T00:00:00Z')]), 'review');
97
+ });
98
+
99
+ test('latestJob 按 startedAt 取最新的一条', () => {
100
+ const list = [
101
+ job('j1', 't1', 'error', '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z'),
102
+ job('j2', 't1', 'done', '2026-01-02T00:00:00Z', '2026-01-02T00:01:00Z'),
103
+ ];
104
+ assert.equal(latestJob(list).id, 'j2');
105
+ assert.equal(latestJob([]), null);
106
+ });
107
+
108
+ // ── 项目合并去重 ────────────────────────────────────────────────────
109
+
110
+ test('buildProjectEntries: 最近目录与任务路径取并集去重,盘符大小写归一', () => {
111
+ const entries = buildProjectEntries({
112
+ recentDirs: ['e:\\ws\\proj-a', 'D:\\ws\\proj-b'],
113
+ tasks: [
114
+ { id: 't1', projectPath: 'E:\\ws\\proj-a' }, // 与最近目录同一项目(盘符大小写不同)
115
+ { id: 't2', projectPath: 'D:\\ws\\proj-c' }, // 只在任务里出现
116
+ { id: 't3', projectPath: '' }, // 未关联项目,不进清单
117
+ ],
118
+ });
119
+ const byKey = new Map(entries.map(e => [e.key, e]));
120
+ assert.equal(entries.length, 3);
121
+ assert.equal(byKey.get('E:\\ws\\proj-a').source, 'both');
122
+ assert.equal(byKey.get('D:\\ws\\proj-b').source, 'recent');
123
+ assert.equal(byKey.get('D:\\ws\\proj-c').source, 'task');
124
+ assert.equal(byKey.get('D:\\ws\\proj-c').name, 'proj-c');
125
+ });
126
+
127
+ test('summarizeProjectTasks: 按项目汇总列数量、进度、活跃执行与最后活跃时间', () => {
128
+ const tasks = [
129
+ // A 项目:1 个已完成
130
+ { id: 'a1', projectPath: 'D:\\a', subtasks: [sub('a1s', 'done')], updatedAt: '2026-01-01T00:00:00Z' },
131
+ // A 项目:1 个在跑(job running)
132
+ { id: 'a2', projectPath: 'D:\\a', subtasks: [sub('a2s', 'running')], updatedAt: '2026-01-02T00:00:00Z' },
133
+ // B 项目:1 个待处理
134
+ { id: 'b1', projectPath: 'D:\\b', subtasks: [sub('b1s', 'todo')], updatedAt: '2026-01-03T00:00:00Z' },
135
+ ];
136
+ const jobs = [
137
+ job('j1', 'a2', 'running', '2026-01-04T00:00:00Z'),
138
+ job('j2', 'a1', 'done', '2026-01-01T01:00:00Z', '2026-01-01T01:30:00Z'),
139
+ ];
140
+ const stats = summarizeProjectTasks(tasks, jobs);
141
+
142
+ const a = stats.get('D:\\a');
143
+ assert.equal(a.total, 2);
144
+ assert.equal(a.done, 1);
145
+ assert.equal(a.doing, 1);
146
+ assert.equal(a.progress, 50);
147
+ assert.equal(a.runningJobs, 1);
148
+ // 最后活跃时间要把 job 的起止也算进来(任务体本身可能很久没改)
149
+ assert.equal(a.lastActiveAt, '2026-01-04T00:00:00Z');
150
+
151
+ const b = stats.get('D:\\b');
152
+ assert.equal(b.total, 1);
153
+ assert.equal(b.todo, 1);
154
+ assert.equal(b.progress, 0);
155
+ assert.equal(b.runningJobs, 0);
156
+ assert.equal(b.lastActiveAt, '2026-01-03T00:00:00Z');
157
+ });
158
+
159
+ test('decorateTaskForBoard: 只回卡片需要的字段', () => {
160
+ const card = decorateTaskForBoard({
161
+ id: 't1', title: '标题', desc: '描述', projectPath: 'D:\\a',
162
+ subtasks: [sub('s1', 'done'), sub('s2', 'error')],
163
+ attachments: [{ id: 'at1' }],
164
+ createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-02T00:00:00Z',
165
+ }, []);
166
+ assert.equal(card.column, 'review');
167
+ assert.equal(card.type, 'complex');
168
+ assert.equal(card.subtaskCount, 2);
169
+ assert.equal(card.subtaskDoneCount, 1);
170
+ assert.equal(card.subtaskErrorCount, 1);
171
+ assert.equal(card.attachmentCount, 1);
172
+ assert.equal(card.runningJobs, 0);
173
+ });
174
+
175
+ // ── 编排状态与活动流 ────────────────────────────────────────────────
176
+
177
+ test('normalizeOrchestrator: 缺字段/脏数据都要能起来', () => {
178
+ const fresh = normalizeOrchestrator(null);
179
+ assert.equal(fresh.active, true); // 缺省是"调度中"
180
+ assert.deepEqual(fresh.instructions, []);
181
+
182
+ assert.equal(normalizeOrchestrator({ active: false }).active, false);
183
+ assert.equal(normalizeOrchestrator({ active: 'no' }).active, true); // 只有显式 false 才算暂停
184
+
185
+ const withJunk = normalizeOrchestrator({ instructions: [null, 5, { text: 'hi' }] });
186
+ assert.equal(withJunk.instructions.length, 1);
187
+ assert.equal(withJunk.instructions[0].text, 'hi');
188
+ assert.equal(withJunk.instructions[0].status, 'created');
189
+ });
190
+
191
+ test('buildActivityFeed: job 起止合成两条记录,与人类指令一起按时间倒序', () => {
192
+ const jobs = [{
193
+ id: 'j1', taskId: 't1', subId: 's1', status: 'done', pid: 123,
194
+ startedAt: '2026-01-01T00:00:00Z', endedAt: '2026-01-01T00:05:00Z', exitCode: 0,
195
+ }];
196
+ const tasks = [{
197
+ id: 't1', projectPath: 'D:\\ws\\proj-a',
198
+ subtasks: [{ id: 's1', title: '子任务一', status: 'done' }],
199
+ title: '任务一',
200
+ }];
201
+ const instructions = [{
202
+ id: 'i1', text: '把这些重构一下', projectPath: 'D:\\ws\\proj-a',
203
+ at: '2026-01-01T00:10:00Z', status: 'accepted',
204
+ }];
205
+
206
+ const feed = buildActivityFeed({ jobs, tasks, instructions });
207
+ assert.equal(feed.length, 3);
208
+ assert.deepEqual(feed.map(r => r.kind), ['user', 'done', 'dispatch']);
209
+ assert.equal(feed[1].taskTitle, '任务一');
210
+ assert.equal(feed[1].subTitle, '子任务一');
211
+ assert.equal(feed[1].projectName, 'proj-a');
212
+ assert.equal(feed[2].pid, 123);
213
+ assert.equal(feed[0].instructionStatus, 'accepted');
214
+ });
215
+
216
+ test('buildActivityFeed: 失败与取消分别落成 error / cancelled', () => {
217
+ const jobs = [
218
+ { id: 'j1', taskId: 't1', subId: 's1', status: 'error', startedAt: '2026-01-01T00:00:00Z', endedAt: '2026-01-01T00:01:00Z', error: 'boom' },
219
+ { id: 'j2', taskId: 't1', subId: 's2', status: 'cancelled', startedAt: '2026-01-01T00:02:00Z', endedAt: '2026-01-01T00:03:00Z' },
220
+ ];
221
+ const feed = buildActivityFeed({ jobs, tasks: [{ id: 't1', projectPath: 'D:\\a', subtasks: [] }] });
222
+ const kinds = feed.map(r => r.kind);
223
+ assert.ok(kinds.includes('error'));
224
+ assert.ok(kinds.includes('cancelled'));
225
+ assert.equal(feed.find(r => r.kind === 'error').error, 'boom');
226
+ });
227
+
228
+ test('buildActivityFeed: 任务已被删除时仍能出记录(不留孤儿空白)', () => {
229
+ const jobs = [{ id: 'j1', taskId: 'gone', subId: 's1', status: 'done', startedAt: '2026-01-01T00:00:00Z', endedAt: '2026-01-01T00:01:00Z' }];
230
+ const feed = buildActivityFeed({ jobs, tasks: [] });
231
+ assert.equal(feed.length, 2);
232
+ assert.equal(feed[0].taskTitle, '');
233
+ assert.equal(feed[0].projectName, '');
234
+ });
235
+
236
+ test('buildRunningAgents: 只列活跃 job,到 job 粒度而不是任务粒度', () => {
237
+ const tasks = [{
238
+ id: 't1', projectPath: 'D:\\ws\\proj-a', subtasks: [
239
+ { id: 's1', title: '子任务一', status: 'running' },
240
+ { id: 's2', title: '子任务二', status: 'running' },
241
+ ],
242
+ }];
243
+ const jobs = [
244
+ { id: 'j1', taskId: 't1', subId: 's1', status: 'running', pid: 111, startedAt: '2026-01-01T00:02:00Z' },
245
+ { id: 'j2', taskId: 't1', subId: 's2', status: 'pending', pid: 222, startedAt: '2026-01-01T00:01:00Z' },
246
+ { id: 'j3', taskId: 't1', subId: 's3', status: 'done', pid: 333, startedAt: '2026-01-01T00:00:00Z' },
247
+ ];
248
+ const running = buildRunningAgents({ jobs, tasks });
249
+ assert.equal(running.length, 2);
250
+ // 最新的排在前面
251
+ assert.equal(running[0].jobId, 'j1');
252
+ assert.equal(running[0].subTitle, '子任务一');
253
+ assert.equal(running[0].projectName, 'proj-a');
254
+ assert.equal(running[1].status, 'pending');
255
+ });
256
+
257
+ // ── 任务详情(点卡片弹窗用) ────────────────────────────────────────
258
+
259
+ test('trimJobForDetail: 丢 prompt/thinking,output 只留尾部并标记被截断', () => {
260
+ const big = 'x'.repeat(OUTPUT_TAIL_CHARS + 500) + 'END';
261
+ const j = {
262
+ id: 'j1', taskId: 't1', subId: 's1', title: '跑一下', status: 'done',
263
+ prompt: 'P'.repeat(10000), thinking: 'T'.repeat(10000),
264
+ output: big, pid: 42, startedAt: '2026-01-01T00:00:00Z', endedAt: '2026-01-01T00:01:00Z',
265
+ exitCode: 0, error: '',
266
+ };
267
+ const t = trimJobForDetail(j);
268
+ assert.equal(t.outputTail.length, OUTPUT_TAIL_CHARS);
269
+ assert.equal(t.outputTruncated, true);
270
+ assert.equal(t.hasOutput, true);
271
+ // 保留的是尾部,不是头部
272
+ assert.equal(t.outputTail.endsWith('END'), true);
273
+ assert.equal(t.outputTruncated === true && t.outputTail.startsWith('x'), true);
274
+ // 大字段必须被丢掉,否则弹窗会把整份日志搬进 DOM
275
+ assert.equal('prompt' in t, false);
276
+ assert.equal('thinking' in t, false);
277
+ assert.equal(t.pid, 42);
278
+ assert.equal(t.exitCode, 0);
279
+ });
280
+
281
+ test('trimJobForDetail: 短输出原样保留且不谎报截断', () => {
282
+ const t = trimJobForDetail({ id: 'j1', status: 'error', output: 'boom', error: '炸了' });
283
+ assert.equal(t.outputTail, 'boom');
284
+ assert.equal(t.outputTruncated, false);
285
+ assert.equal(t.error, '炸了');
286
+ assert.equal(t.pid, null);
287
+ assert.equal(t.exitCode, null);
288
+ });
289
+
290
+ test('trimJobForDetail: null / 无 output 不炸', () => {
291
+ assert.equal(trimJobForDetail(null), null);
292
+ const t = trimJobForDetail({ id: 'j2', status: 'pending' });
293
+ assert.equal(t.outputTail, '');
294
+ assert.equal(t.outputTruncated, false);
295
+ assert.equal(t.hasOutput, false);
296
+ });
297
+
298
+ test('buildTaskDetail: 任务缺失返回 null(交路由转 404)', () => {
299
+ assert.equal(buildTaskDetail(null, []), null);
300
+ assert.equal(buildTaskDetail({}, []), null);
301
+ });
302
+
303
+ test('buildTaskDetail: lastJob 取最新一条,recentJobs 倒序且被限制条数', () => {
304
+ const task = {
305
+ id: 't1', title: '任务', desc: '', type: 'complex',
306
+ subtasks: [sub('s1', 'done'), sub('s2', 'running')],
307
+ attachments: [{ id: 'a1' }],
308
+ };
309
+ const jobs = [
310
+ job('j1', 't1', 'done', '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z'),
311
+ job('j3', 't1', 'running', '2026-01-01T00:02:00Z'),
312
+ job('j2', 't1', 'done', '2026-01-01T00:01:00Z', '2026-01-01T00:02:00Z'),
313
+ ];
314
+ const d = buildTaskDetail(task, jobs);
315
+ assert.equal(d.task.id, 't1');
316
+ assert.equal(d.jobCount, 3);
317
+ // 最新的是 j3(有 startedAt 无 endedAt,时间戳取 startedAt)
318
+ assert.equal(d.lastJob.id, 'j3');
319
+ assert.equal(d.recentJobs[0].id, 'j3');
320
+ assert.equal(d.recentJobs[1].id, 'j2');
321
+ assert.equal(d.recentJobs[2].id, 'j1');
322
+ // 有 job 在跑 → 进行中
323
+ assert.equal(d.column, 'doing');
324
+ });
325
+
326
+ test('buildTaskDetail: recentJobLimit 生效;无 job 时 lastJob 为 null 而不是抛错', () => {
327
+ const task = { id: 't2', title: '', desc: '', type: 'simple', subtasks: [] };
328
+ const jobs = [1, 2, 3, 4, 5, 6].map(i => job('j' + i, 't2', 'done', `2026-01-0${i}T00:00:00Z`));
329
+ const d = buildTaskDetail(task, jobs, { recentJobLimit: 2 });
330
+ assert.equal(d.recentJobs.length, 2);
331
+ assert.equal(d.jobCount, 6);
332
+ assert.equal(d.lastJob.id, 'j6');
333
+
334
+ const empty = buildTaskDetail(task, []);
335
+ assert.equal(empty.lastJob, null);
336
+ assert.deepEqual(empty.recentJobs, []);
337
+ assert.equal(empty.jobCount, 0);
338
+ assert.equal(empty.column, 'todo');
339
+ });
@@ -0,0 +1,51 @@
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
+ // 任务 prompt 正文的拼装规则(纯函数,无副作用)。
16
+ //
17
+ // 为什么单独成一个文件:taskRunner.js 顶层 import 了 jobStore.js,而后者在**模块加载时**
18
+ // 就调用 hydrateJobs() 并回写 ~/.zen-gitsync/jobs.json(还会把陈旧的 running/pending 降级为
19
+ // error 后落盘)。单测若直接 import taskRunner 就会碰到真实用户数据 ——
20
+ // 所以把"纯拼装"这一小块剥出来,测试只碰它。
21
+
22
+ /**
23
+ * 拼任务 prompt 的正文 = 提示词模板 + 子任务标题 + 子任务描述。
24
+ *
25
+ * 去重规则只作用于「标题 / 描述」这一对:
26
+ * 主 Agent 派发(POST /api/workbench/orchestrator/dispatch)建的简单任务里
27
+ * `title = text.split('\n')[0].slice(0, 120)`、`desc = text` —— 单行指令时两者逐字相同,
28
+ * 直接拼会得到「帮我拉取一下代码\n\n帮我拉取一下代码」:白烧 token,而且让 prompt
29
+ * 看起来像谁把话说了两遍。首行超过 120 字时 title 是首行的**前缀**,所以用
30
+ * startsWith 兜住这个截断情形(判定重复只要求"标题已被描述完整包含",不丢信息)。
31
+ *
32
+ * 模板(interpolated)刻意不参与去重:那是用户显式配置的提示词,
33
+ * 与标题撞车与否该由用户决定,代码不擅自删。
34
+ *
35
+ * @param {string} template 已插值的提示词模板,可为空
36
+ * @param {string} title 子任务标题
37
+ * @param {string} desc 子任务描述
38
+ * @returns {string} 用空行分隔的非空段落
39
+ */
40
+ export function composePromptBody(template, title, desc) {
41
+ const t = (title || '').trim();
42
+ const d = (desc || '').trim();
43
+ // 描述首行 —— title 由它截断而来,这是判定"重复"的依据
44
+ const firstLine = d.split('\n')[0];
45
+ const titleRedundant = !!t && (t === d || firstLine.startsWith(t));
46
+
47
+ // 判定用 trim 后的值,但拼进 prompt 的仍是**原值**:
48
+ // 只删掉重复的那一段,其余一个字符都不动(缩进、结尾换行都保持原样)。
49
+ const body = titleRedundant ? [desc] : [title, desc];
50
+ return [template, ...body].filter(s => s && s.trim()).join('\n\n');
51
+ }
@@ -0,0 +1,82 @@
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
+ // prompt 正文拼装的单测。
16
+ //
17
+ // 回归目标:主 Agent 派发建的简单任务(title = desc 首行)曾把同一句话拼两遍进 prompt ——
18
+ // 实测 job.prompt 是「帮我拉取一下代码\n\n帮我拉取一下代码」。
19
+ // 这里把"什么算重复、什么不算"钉死,避免以后又把用户手填的标题误删、或漏掉截断情形。
20
+
21
+ import { test } from 'node:test';
22
+ import assert from 'node:assert/strict';
23
+
24
+ import { composePromptBody } from './promptParts.js';
25
+
26
+ test('单行指令:title 与 desc 逐字相同时只出现一次', () => {
27
+ const out = composePromptBody('', '帮我拉取一下代码', '帮我拉取一下代码');
28
+ assert.equal(out, '帮我拉取一下代码');
29
+ // 反例锚点:这正是修复前的产物
30
+ assert.notEqual(out, '帮我拉取一下代码\n\n帮我拉取一下代码');
31
+ });
32
+
33
+ test('多行描述:title 就是首行时只留描述全文(首行不重复)', () => {
34
+ const out = composePromptBody('', '重构登录', '重构登录\n把错误处理抽成 composable');
35
+ assert.equal(out, '重构登录\n把错误处理抽成 composable');
36
+ });
37
+
38
+ test('派发路径的截断:title 是超长首行的前 120 字时仍判为重复', () => {
39
+ const firstLine = 'A'.repeat(200);
40
+ const title = firstLine.slice(0, 120); // 与 dispatch 路由的 slice(0,120) 同口径
41
+ const desc = `${firstLine}\n第二行`;
42
+ assert.equal(composePromptBody('', title, desc), desc);
43
+ });
44
+
45
+ test('标题被描述完整包含(后接其它文字)也算重复 —— 描述已含标题的全部信息', () => {
46
+ const out = composePromptBody('', '拉取代码', '拉取代码(顺便更新依赖)');
47
+ assert.equal(out, '拉取代码(顺便更新依赖)');
48
+ });
49
+
50
+ test('用户手填的标题与描述不同:两段都要保留', () => {
51
+ const out = composePromptBody('', '修复登录白屏', '用户反馈登录页点确定后白屏,帮我定位');
52
+ assert.equal(out, '修复登录白屏\n\n用户反馈登录页点确定后白屏,帮我定位');
53
+ });
54
+
55
+ test('标题只是描述中间出现过的词,不算重复(不做子串匹配)', () => {
56
+ const out = composePromptBody('', '登录', '修复登录白屏');
57
+ // '修复登录白屏' 的首行不以 '登录' 开头 → 两段都在
58
+ assert.equal(out, '登录\n\n修复登录白屏');
59
+ });
60
+
61
+ test('模板 + 不重复的标题/描述:三段按序拼接', () => {
62
+ const out = composePromptBody('请用中文回答', '重构登录', '把错误处理抽成 composable');
63
+ assert.equal(out, '请用中文回答\n\n重构登录\n\n把错误处理抽成 composable');
64
+ });
65
+
66
+ test('模板不参与去重:与标题相同也不删', () => {
67
+ const out = composePromptBody('重构登录', '重构登录', '把错误处理抽成 composable');
68
+ assert.equal(out, '重构登录\n\n重构登录\n\n把错误处理抽成 composable');
69
+ });
70
+
71
+ test('空段一律不占位,不产生多余空行', () => {
72
+ assert.equal(composePromptBody('', '', '仅描述'), '仅描述');
73
+ assert.equal(composePromptBody('', '仅标题', ''), '仅标题');
74
+ assert.equal(composePromptBody('模板', '', ''), '模板');
75
+ assert.equal(composePromptBody('', '', ''), '');
76
+ assert.equal(composePromptBody(' ', ' ', '\n'), '');
77
+ });
78
+
79
+ test('判定用 trim 值,但拼进 prompt 的是原值(缩进与结尾换行保持原样)', () => {
80
+ const desc = ' 缩进的描述\n';
81
+ assert.equal(composePromptBody('', ' 缩进的描述\n', desc), desc);
82
+ });
@@ -44,6 +44,11 @@ export const JOBS_CONFIG_FILE = path.join(DATA_DIR, 'jobs-config.json');
44
44
  export const JOBS_SAVE_DEBOUNCE_MS = 1500;
45
45
  export const DEFAULT_JOBS_CONFIG = { maxCount: 500, maxSizeMB: 256 };
46
46
 
47
+ // 主 Agent 编排台:调度开关(active)+ 人类干预指令存档。
48
+ // 指令存档是「我说过什么」的流水,不参与执行逻辑,只用于控制台日志流回放。
49
+ export const ORCHESTRATOR_FILE = path.join(DATA_DIR, 'orchestrator.json');
50
+ export const MAX_ORCHESTRATOR_INSTRUCTIONS = 200;
51
+
47
52
  // 子项目识别 / 文件扫描时需要跳过的目录
48
53
  export const SKIP_DIRS = new Set([
49
54
  'node_modules', 'dist', 'build', '.next', '.nuxt', '__pycache__',
@@ -38,6 +38,7 @@ import {
38
38
  interpolate,
39
39
  } from './shared.js';
40
40
  import { buildAttachmentBlock } from './pdfText.js';
41
+ import { composePromptBody } from './promptParts.js';
41
42
  import {
42
43
  jobs,
43
44
  cancelledJobs,
@@ -211,8 +212,8 @@ export async function runSingleSubtask(task, sub, repoPath, branch, priorOutputs
211
212
  branch: branch || ''
212
213
  };
213
214
  const interpolated = interpolate(promptTemplate, ctx);
214
- const parts = [interpolated, sub.title, sub.desc].filter(s => s && s.trim());
215
- let prompt = parts.join('\n\n');
215
+ // 标题与描述可能逐字相同(派发建的简单任务就是),去重规则见 promptParts.js
216
+ let prompt = composePromptBody(interpolated, sub.title, sub.desc);
216
217
 
217
218
  // ── 前序上下文:把前几个 done 子任务的输出完整拼到 prompt 头部 ──
218
219
  if (priorOutputs && priorOutputs.length > 0) {
@@ -12,14 +12,16 @@
12
12
  // See the License for the specific language governing permissions and
13
13
  // limitations under the License.
14
14
  //
15
- // 批量探测目录的 Git 状态(是不是仓库 / 有没有未提交改动)。
15
+ // 批量探测目录的 Git 状态(是不是仓库 / 有没有未提交改动 / 与上游差多少)。
16
16
  //
17
17
  // 用途:「最近项目」「常用目录」列表要在卡片上标出 Git 状态,列表里可能有十几个目录,
18
- // 因此这里做三件事:
18
+ // 因此这里做四件事:
19
19
  // 1. 每个目录只跑**一次** git(status 成功即说明是仓库,失败即非仓库),
20
- // 避免 rev-parse + status 两次 spawn;
20
+ // 并用 --branch 顺带拿到分支与领先/落后,避免额外 3 spawn;
21
21
  // 2. 并发上限 + 单目录超时,别让某个大仓库(或网络盘)拖死整个请求;
22
- // 3. 结果短 TTL 缓存,弹窗反复打开不必重复扫盘。
22
+ // 3. 结果短 TTL 缓存,弹窗反复打开不必重复扫盘;
23
+ // 4. 领先/落后只读**本地 remote-tracking 引用,不联网 fetch** —— 探测十几个目录
24
+ // 去联网是不可接受的;口径与应用里那条「你的分支落后」提示一致。
23
25
  //
24
26
  // 不用 src/utils/index.js 的 execGitCommand:那个函数固定跑在当前项目 cwd,
25
27
  // 且会把每次调用写进命令历史和 socket 广播 —— 探测十几个无关目录不该污染这些。
@@ -46,14 +48,23 @@ export function normalizeDirKey(dirPath) {
46
48
 
47
49
  /** 空状态(非仓库 / 探测失败时复用),避免各处手写零值 */
48
50
  function emptyState(extra = {}) {
49
- return { exists: true, isGitRepo: false, changed: 0, staged: 0, unstaged: 0, untracked: 0, ...extra };
51
+ return {
52
+ exists: true,
53
+ isGitRepo: false,
54
+ // 工作区计数
55
+ changed: 0, staged: 0, unstaged: 0, untracked: 0,
56
+ // 分支与上游跟踪
57
+ branch: null, upstream: null, hasUpstream: false, detached: false, ahead: 0, behind: 0,
58
+ ...extra,
59
+ };
50
60
  }
51
61
 
52
62
  /**
53
- * 解析 `git status --porcelain=v1` 输出 → 计数。
63
+ * 解析 `git status --porcelain=v1` 输出 → 工作区计数。
54
64
  * 纯函数,单测覆盖。
55
65
  *
56
66
  * 格式:`XY path`,X=暂存区状态,Y=工作区状态。
67
+ * - `## ...` → 分支头(见 parseBranchTracking),**不计入改动**
57
68
  * - `??` → 未跟踪
58
69
  * - `!!` → 被忽略(porcelain 默认不输出,防御性跳过)
59
70
  * - 其余非空位 → 计入暂存/未暂存(冲突如 `UU` 会同时计入两边)
@@ -63,6 +74,9 @@ export function parsePorcelainStatus(stdout) {
63
74
  const result = { changed: 0, staged: 0, unstaged: 0, untracked: 0 };
64
75
  for (const rawLine of String(stdout || '').split('\n')) {
65
76
  const line = rawLine.replace(/\r$/, '');
77
+ // 必须显式跳过分支头:`## main...origin/main` 的前两个字符是 `#`,
78
+ // 既不是 `!!` 也不是 `??`,漏掉会把它算成 1 个"已暂存 + 未暂存"的文件。
79
+ if (line.startsWith('##')) continue;
66
80
  if (line.length < 3) continue;
67
81
  const x = line[0];
68
82
  const y = line[1];
@@ -78,6 +92,68 @@ export function parsePorcelainStatus(stdout) {
78
92
  return result;
79
93
  }
80
94
 
95
+ /**
96
+ * 解析 `git status --porcelain=v1 --branch` 的 `##` 头 → 分支与领先/落后。
97
+ * 纯函数,单测覆盖。
98
+ *
99
+ * 见过的几种形状:
100
+ * `## main...origin/main [ahead 1, behind 2]` 有上游且有差异
101
+ * `## main...origin/main` 有上游且同步
102
+ * `## main...origin/main [gone]` 上游被删了(ahead/behind 无意义)
103
+ * `## main` 有分支但没设上游
104
+ * `## HEAD (no branch)` 分离 HEAD
105
+ * `## No commits yet on main` 空仓库(老 git 是 `## Initial commit on main`)
106
+ *
107
+ * 领先/落后取自**本地 remote-tracking 引用**,不联网 fetch —— 与应用里那条
108
+ * 「你的分支落后 'origin/develop' 17 个提交」同一口径(都基于上次 fetch 的结果)。
109
+ */
110
+ export function parseBranchTracking(stdout) {
111
+ const result = { branch: null, upstream: null, hasUpstream: false, detached: false, ahead: 0, behind: 0 };
112
+ const headLine = String(stdout || '')
113
+ .split('\n')
114
+ .map(l => l.replace(/\r$/, ''))
115
+ .find(l => l.startsWith('## '));
116
+ if (!headLine) return result;
117
+
118
+ let head = headLine.slice(3).trim();
119
+ // 跟踪信息固定在末尾方括号里,先摘掉再拆分支名(分支名里不可能出现 `[`)
120
+ const bracket = head.match(/\s+\[(.+)\]$/);
121
+ let tracking = '';
122
+ if (bracket) {
123
+ tracking = bracket[1];
124
+ head = head.slice(0, head.length - bracket[0].length).trim();
125
+ }
126
+
127
+ if (/^HEAD \(no branch\)/.test(head)) {
128
+ result.detached = true;
129
+ return result;
130
+ }
131
+
132
+ const fresh = head.match(/^(?:No commits yet on|Initial commit on)\s+(.+)$/);
133
+ if (fresh) {
134
+ result.branch = fresh[1].trim();
135
+ return result;
136
+ }
137
+
138
+ // git 的 ref 命名规则禁止 `..`,所以 `...` 只会是分隔符
139
+ const sep = head.indexOf('...');
140
+ if (sep === -1) {
141
+ result.branch = head;
142
+ } else {
143
+ result.branch = head.slice(0, sep);
144
+ result.upstream = head.slice(sep + 3);
145
+ // [gone] 表示上游引用已不存在,这时不算"有上游可比较"
146
+ result.hasUpstream = !/gone/.test(tracking);
147
+ }
148
+
149
+ const ahead = tracking.match(/ahead (\d+)/);
150
+ const behind = tracking.match(/behind (\d+)/);
151
+ if (ahead) result.ahead = Number(ahead[1]);
152
+ if (behind) result.behind = Number(behind[1]);
153
+
154
+ return result;
155
+ }
156
+
81
157
  function execFileAsync(file, args, options) {
82
158
  return new Promise((resolve, reject) => {
83
159
  execFile(file, args, options, (error, stdout, stderr) => {
@@ -114,6 +190,10 @@ export async function probeDirectoryGitState(dirPath, { timeoutMs = DEFAULT_PROB
114
190
  '--no-optional-locks',
115
191
  'status',
116
192
  '--porcelain=v1',
193
+ // --branch 让 status 顺带输出 `## main...origin/main [ahead 1, behind 2]`,
194
+ // 领先/落后就白拿 —— 否则要再起 3 个进程(symbolic-ref + rev-parse @{u} + rev-list)。
195
+ // 只读本地 remote-tracking 引用,**不联网**。
196
+ '--branch',
117
197
  // normal 会把未跟踪目录折叠成一条,大目录(node_modules 之类)下比 all 快很多,
118
198
  // 而这里只需要"有没有 / 有几项"
119
199
  '--untracked-files=normal',
@@ -129,7 +209,12 @@ export async function probeDirectoryGitState(dirPath, { timeoutMs = DEFAULT_PROB
129
209
  },
130
210
  }
131
211
  );
132
- return { exists: true, isGitRepo: true, ...parsePorcelainStatus(stdout) };
212
+ return {
213
+ exists: true,
214
+ isGitRepo: true,
215
+ ...parsePorcelainStatus(stdout),
216
+ ...parseBranchTracking(stdout),
217
+ };
133
218
  } catch (error) {
134
219
  const text = `${error?.stderr || ''}\n${error?.message || ''}`;
135
220
  if (/not a git repository|not a git repo/i.test(text)) {