zen-gitsync 2.17.17 → 2.17.18

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 (62) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/paths.js +10 -0
  4. package/src/ui/public/assets/{AgentView-CXZUWLPL.css → AgentView-AMFKSDeH.css} +1 -1
  5. package/src/ui/public/assets/AgentView-DQAB0ypS.js +13 -0
  6. package/src/ui/public/assets/{AppVersionBadge-D70AjIBo.js → AppVersionBadge-B5VG1_Lu.js} +2 -2
  7. package/src/ui/public/assets/{BranchSelector-t3fnGS_5.js → BranchSelector-Cj6athRO.js} +1 -1
  8. package/src/ui/public/assets/{CommitForm-DmlTnTwc.js → CommitForm-a2tMsfF9.js} +1 -1
  9. package/src/ui/public/assets/{CommonDialog-CecgvEzd.js → CommonDialog-I-GCqRMC.js} +1 -1
  10. package/src/ui/public/assets/EditorView-wTi53Nxc.js +16 -0
  11. package/src/ui/public/assets/{FileDiffViewer-DH8Y2lUO.js → FileDiffViewer-B7i4BmbH.js} +1 -1
  12. package/src/ui/public/assets/{FlowExecutionViewer-Cie2_TG4.js → FlowExecutionViewer-xZjrf8e1.js} +1 -1
  13. package/src/ui/public/assets/{FlowOrchestrationWorkspace-Bbf8X1KP.js → FlowOrchestrationWorkspace-R0QKmUpG.js} +1 -1
  14. package/src/ui/public/assets/{IconButton-BEiZI4F6.js → IconButton-B6eqvssJ.js} +1 -1
  15. package/src/ui/public/assets/{LogList-DvuC03Tx.js → LogList-BPqurE9g.js} +1 -1
  16. package/src/ui/public/assets/{MindmapView-B6rITyNs.js → MindmapView-NdFNlkTF.js} +1 -1
  17. package/src/ui/public/assets/{MonitorView-q92EZvay.js → MonitorView-DuSZa8a3.js} +1 -1
  18. package/src/ui/public/assets/{ProjectStartupButton-RiIi8TwW.js → ProjectStartupButton-BrfKVP-w.js} +1 -1
  19. package/src/ui/public/assets/{RemoteManagerDialog-qtBotYAQ.js → RemoteManagerDialog-BI7fRsOQ.js} +1 -1
  20. package/src/ui/public/assets/{RemoteRepoCard-DvtzfwHV.js → RemoteRepoCard-B4E8Ud3F.js} +1 -1
  21. package/src/ui/public/assets/{SourceMapView-EEWAmy4U.js → SourceMapView-B0OzHjxP.js} +1 -1
  22. package/src/ui/public/assets/{SvgIcon-Bkc_oGUI.js → SvgIcon-B2h7B2yF.js} +1 -1
  23. package/src/ui/public/assets/{TemplateManager-9e4amMlI.js → TemplateManager-mIWE3Qf8.js} +1 -1
  24. package/src/ui/public/assets/{UserInputNode-4FoJblJi.js → UserInputNode-BafC-B2U.js} +1 -1
  25. package/src/ui/public/assets/{WorkbenchView-BFaTinH8.js → WorkbenchView-BseGz8hJ.js} +5 -5
  26. package/src/ui/public/assets/{_plugin-vue_export-helper-BLGowG5t.js → _plugin-vue_export-helper-Dvuzpv2Y.js} +4 -4
  27. package/src/ui/public/assets/agentConversations-BEKqkGcm.js +5 -0
  28. package/src/ui/public/assets/{configStore-BUXv5i5j.js → configStore-CX0FkQ94.js} +1 -1
  29. package/src/ui/public/assets/{gitStore-BGbEYd0U.js → gitStore-CMuWWr2d.js} +1 -1
  30. package/src/ui/public/assets/{index-Di5cimoI.js → index-CB2cIiHl.js} +12 -12
  31. package/src/ui/public/assets/{previewSandbox-DEQpyVNu.js → previewSandbox-DxR4iN9P.js} +0 -0
  32. package/src/ui/public/index.html +10 -10
  33. package/src/ui/server/index.js +28 -2
  34. package/src/ui/server/routes/aiContext/collectors.js +360 -0
  35. package/src/ui/server/routes/aiContext/collectors.test.js +297 -0
  36. package/src/ui/server/routes/aiContext/index.js +444 -0
  37. package/src/ui/server/routes/aiContext/index.test.js +433 -0
  38. package/src/ui/server/routes/aiContext/jobRefresh.js +110 -0
  39. package/src/ui/server/routes/aiContext/jobRefresh.test.js +191 -0
  40. package/src/ui/server/routes/aiContext/render.js +366 -0
  41. package/src/ui/server/routes/aiContext/render.test.js +255 -0
  42. package/src/ui/server/routes/aiContext/wiring.js +80 -0
  43. package/src/ui/server/routes/fs.js +43 -0
  44. package/src/ui/server/routes/mindmap.js +36 -24
  45. package/src/ui/server/routes/monitor.js +44 -35
  46. package/src/ui/server/routes/remoteRepos.js +57 -48
  47. package/src/ui/server/routes/workbench/agentChat.js +35 -7
  48. package/src/ui/server/routes/workbench/agentChatContext.test.js +66 -1
  49. package/src/ui/server/routes/workbench/agentEngines.js +77 -0
  50. package/src/ui/server/routes/workbench/agentEngines.test.js +65 -0
  51. package/src/ui/server/routes/workbench/agentRoutes.js +169 -44
  52. package/src/ui/server/routes/workbench/agentRoutes.test.js +66 -4
  53. package/src/ui/server/routes/workbench/agentSessionStore.js +3 -0
  54. package/src/ui/server/routes/workbench/envContext.js +61 -64
  55. package/src/ui/server/routes/workbench/envContext.test.js +133 -63
  56. package/src/ui/server/routes/workbench/index.js +78 -32
  57. package/src/ui/server/routes/workbench/runExternalTurn.js +279 -0
  58. package/src/ui/server/routes/workbench/runExternalTurn.test.js +275 -0
  59. package/src/ui/server/routes/workbench/shared.js +17 -0
  60. package/src/ui/public/assets/AgentView-Dhs3eKFY.js +0 -7
  61. package/src/ui/public/assets/EditorView-DVRemzZU.js +0 -16
  62. package/src/ui/public/assets/agentConversations-BQCIv4u1.js +0 -5
@@ -0,0 +1,433 @@
1
+ // aiContext/index.js 单元测试:按板块生成、定向刷新、落盘、缓存、失败隔离、**非阻塞**。
2
+ //
3
+ // 全部 IO 走注入的假实现 + mkdtemp 出来的临时目录,不联网、不 spawn、
4
+ // 不碰 ~/.zen-gitsync 里的真实数据。
5
+ //
6
+ // 两条最重要的契约:
7
+ // ① 读路径(getSnapshot / getBlock)**永不等待生成** —— 最初的实现 await 生成,
8
+ // 结果"用户按下发送"到"模型吐第一个字"之间被 gh/gitee/PowerShell 串起来拖了十几秒,
9
+ // 直接挂掉了一个既有回归测试。
10
+ // ② **按板块刷新** —— 七个板块成本差两个数量级,刷 git 不该顺带联网拉仓库列表。
11
+ // 所以每个板块有自己的 ttl / force 地板,且只重写自己那个文件。
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import fs from 'node:fs/promises';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ import { createAiContextSnapshotter } from './index.js';
19
+ import { INDEX_FILE, sectionTtl } from './render.js';
20
+
21
+ async function tmpDir() {
22
+ return fs.mkdtemp(path.join(os.tmpdir(), 'zen-ai-context-'));
23
+ }
24
+
25
+ /** 一组"全都正常"的假依赖;单个用例用 over 覆盖其中一项来制造故障 */
26
+ function makeDeps(over = {}) {
27
+ const calls = { github: 0, gitee: 0, git: 0 };
28
+ const deps = {
29
+ configManager: {
30
+ loadConfig: async () => ({ customCommands: [{ id: '1', name: 'dev', command: 'npm run dev' }], ui: { mindmapDirs: ['D:/ws/mm'] } }),
31
+ getRecentDirectories: async () => ['D:/ws/a'],
32
+ },
33
+ getCurrentProjectPath: () => 'D:/ws/a',
34
+ readTasks: async () => [{ id: 't1', title: '任务一', projectPath: 'D:/ws/a', updatedAt: '2026-09-28T10:00:00Z' }],
35
+ readJobs: async () => [],
36
+ probeGit: async () => {
37
+ calls.git += 1;
38
+ return { exists: true, isGitRepo: true, branch: 'main', hasUpstream: true, ahead: 1, behind: 0, changed: 2, staged: 1, unstaged: 1, untracked: 0 };
39
+ },
40
+ loadRemoteReposState: async (provider) => {
41
+ if (provider === 'github') calls.github += 1;
42
+ if (provider === 'gitee') calls.gitee += 1;
43
+ return { installed: false };
44
+ },
45
+ getMonitorOverview: async () => ({ cpu: { usage: 1.2 }, memory: { usagePercent: 50 }, disks: null, system: { hostname: 'H' } }),
46
+ listPorts: async () => [],
47
+ listMindmapFiles: async () => [],
48
+ truthFiles: { tasksFile: 'C:/u/.zen-gitsync/tasks.json', jobsFile: 'C:/u/.zen-gitsync/jobs.json', configFile: 'C:/u/.zen-gitsync/config.json' },
49
+ now: () => 1000,
50
+ ...over,
51
+ };
52
+ return { deps, calls };
53
+ }
54
+
55
+ const readFile = (dir, name) => fs.readFile(path.join(dir, name), 'utf-8');
56
+
57
+ test('生成:写出七个板块文件 + INDEX,文件头都带采集时间与实时查询方法', async () => {
58
+ const dir = await tmpDir();
59
+ const { deps } = makeDeps();
60
+ const snap = createAiContextSnapshotter({ ...deps, dir });
61
+
62
+ const state = await snap.refreshAll({ force: true });
63
+ assert.equal(state.sections.length, 7);
64
+ assert.ok(state.sections.every(s => s.collectedAt), '每块都要有自己的采集时间');
65
+
66
+ const files = (await fs.readdir(dir)).sort();
67
+ assert.deepEqual(files, ['INDEX.md', 'custom-commands.md', 'git-current.md', 'mindmap.md', 'remote-gitee.md', 'remote-github.md', 'system.md', 'workbench-tasks.md']);
68
+
69
+ for (const name of files) {
70
+ const text = await readFile(dir, name);
71
+ assert.match(text, /快照/, `${name} 缺"这是快照"的声明`);
72
+ if (name === INDEX_FILE) {
73
+ // INDEX 是汇总,写的是"本次汇总";各板块的采集时间在下面每行里
74
+ assert.match(text, /本次汇总: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/, 'INDEX 缺本次汇总时间');
75
+ continue;
76
+ }
77
+ assert.match(text, /采集时间: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/, `${name} 缺采集时间`);
78
+ }
79
+
80
+ const gitText = await readFile(dir, 'git-current.md');
81
+ assert.match(gitText, /分支: main/);
82
+ assert.match(gitText, /未提交改动: 2 个文件/);
83
+ assert.match(gitText, /要实时值: .*git status --porcelain/, '板块文件里也要带实时查询方法');
84
+
85
+ const index = await readFile(dir, INDEX_FILE);
86
+ assert.match(index, /## 实时查询方法/);
87
+ assert.match(index, /gh repo list/);
88
+ });
89
+
90
+ test('定向刷新:只重跑并重写指定的板块,其它板块文件原地不动', async () => {
91
+ const dir = await tmpDir();
92
+ let clock = 1000;
93
+ const { deps, calls } = makeDeps({ now: () => clock });
94
+ const snap = createAiContextSnapshotter({ ...deps, dir });
95
+
96
+ await snap.refreshAll({ force: true });
97
+ const gitBefore = await readFile(dir, 'git-current.md');
98
+ const ghBefore = await readFile(dir, 'remote-github.md');
99
+ assert.equal(calls.git, 1);
100
+
101
+ // 越过 github 的强制刷新地板(60s)—— 在那之前 force 也不该让它重跑(见下一条用例)
102
+ clock += 60_001;
103
+ await snap.refreshSections(['github'], { force: true });
104
+
105
+ assert.equal(calls.github, 2, 'github 应该被重跑');
106
+ assert.equal(calls.git, 1, 'git 不该被顺带重跑');
107
+ assert.equal(await readFile(dir, 'git-current.md'), gitBefore, 'git 文件不该被重写');
108
+ assert.notEqual(await readFile(dir, 'remote-github.md'), ghBefore, 'github 文件应该被重写');
109
+ // git 板块的采集时间没变,github 的变了 —— 这正是"各板块独立刷新"的可观测证据
110
+ const state = snap.getState();
111
+ assert.equal(state.sections.find(s => s.id === 'git').ageMs, 60_001);
112
+ assert.equal(state.sections.find(s => s.id === 'github').ageMs, 0);
113
+ });
114
+
115
+ test('软 TTL:没过期的板块在 refreshAll 里不会被重跑,只有过期的才跑', async () => {
116
+ const dir = await tmpDir();
117
+ let clock = 1000;
118
+ const { deps, calls } = makeDeps({ now: () => clock });
119
+ const snap = createAiContextSnapshotter({ ...deps, dir });
120
+
121
+ await snap.refreshAll({ force: true });
122
+ assert.equal(calls.git, 1);
123
+ assert.equal(calls.github, 1);
124
+
125
+ // 越过 git 的软 TTL(15s),但远没到 github 的(300s)
126
+ clock += sectionTtl('git') + 1;
127
+ await snap.refreshAll();
128
+
129
+ assert.equal(calls.git, 2, 'git 过期了应该重跑');
130
+ assert.equal(calls.github, 1, 'github 还没过期,不该重跑(联网板块不能被拖着刷)');
131
+ });
132
+
133
+ test('强制刷新地板:force 也刷不动刚拉过的联网板块,本地板块不受限', async () => {
134
+ const dir = await tmpDir();
135
+ let clock = 1000;
136
+ const { deps, calls } = makeDeps({ now: () => clock });
137
+ const snap = createAiContextSnapshotter({ ...deps, dir });
138
+
139
+ await snap.refreshAll({ force: true });
140
+ assert.equal(calls.github, 1);
141
+ assert.equal(calls.git, 1);
142
+
143
+ // 5 秒后用户切了个面板:git 重跑,github 被地板挡住
144
+ clock += 5000;
145
+ await snap.refreshAll({ force: true });
146
+ assert.equal(calls.git, 2, '本地板块 force 就该真跑');
147
+ assert.equal(calls.github, 1, 'github 有 60s 地板,force 也不该穿透');
148
+
149
+ // 过了地板再 force,才允许
150
+ clock += 60_000;
151
+ await snap.refreshAll({ force: true });
152
+ assert.equal(calls.github, 2);
153
+ });
154
+
155
+ test('冷启动:getBlock 立即返回(不等待生成),但块里已经有实时查询方法', async () => {
156
+ const dir = await tmpDir();
157
+ let release;
158
+ const gate = new Promise(resolve => { release = resolve; });
159
+ const { deps } = makeDeps({
160
+ probeGit: async () => {
161
+ await gate; // 模拟"卡在 gh 拉列表 / PowerShell 查磁盘"上
162
+ return { exists: true, isGitRepo: true, branch: 'main', hasUpstream: true, ahead: 0, behind: 0, changed: 0, staged: 0, unstaged: 0, untracked: 0 };
163
+ },
164
+ });
165
+ const snap = createAiContextSnapshotter({ ...deps, dir });
166
+
167
+ // 刻意**不 await**:这两个板块正卡在闸门上。若这里 await,本用例会在取块之前就挂住,
168
+ // 后面的用例会被级联报成 "Promise resolution is still pending"(踩过一次)。
169
+ void snap.refreshSections(['git', 'system']);
170
+
171
+ const block = await Promise.race([
172
+ snap.getBlock(),
173
+ new Promise(resolve => setTimeout(() => resolve('__TIMEOUT__'), 500)),
174
+ ]);
175
+ assert.notEqual(block, '__TIMEOUT__', '取块不许等生成');
176
+ assert.match(block, /要查实时值就照下面来/, '冷启动也要给实时查询方法');
177
+ assert.match(block, /本轮快照还没生成好/, '并说明快照还没好');
178
+
179
+ release();
180
+ await snap.refreshSections(['git', 'system']);
181
+ const after = await snap.getBlock();
182
+ assert.match(after, /各板块摘要/);
183
+ });
184
+
185
+ test('读路径:过期板块先回旧的,同时在后台定向补刷', async () => {
186
+ const dir = await tmpDir();
187
+ let clock = 1000;
188
+ const { deps, calls } = makeDeps({ now: () => clock });
189
+ const snap = createAiContextSnapshotter({ ...deps, dir });
190
+
191
+ await snap.refreshAll({ force: true });
192
+ const gitAt1 = snap.getState().sections.find(s => s.id === 'git').collectedAt;
193
+
194
+ clock += sectionTtl('git') + 1;
195
+ const snap1 = await snap.getSnapshot();
196
+ assert.ok(snap1, '过期也要先回旧的,不能回空');
197
+ assert.equal(snap1.results.find(r => r.id === 'git').collectedAt, gitAt1, '回的应该是旧的那份');
198
+
199
+ // 后台那次刷新的 Promise 与显式调用共享,await 一下让它落地
200
+ await snap.refreshSections(['git']);
201
+ assert.equal(calls.git, 2);
202
+ assert.notEqual(snap.getState().sections.find(s => s.id === 'git').collectedAt, gitAt1);
203
+ });
204
+
205
+ test('并发:同一板块的并发生成只跑一次(几个时机同时触发时不会各拉一次网络)', async () => {
206
+ const dir = await tmpDir();
207
+ const { deps, calls } = makeDeps();
208
+ const snap = createAiContextSnapshotter({ ...deps, dir });
209
+
210
+ await Promise.all([
211
+ snap.refreshAll({ force: true }),
212
+ snap.refreshAll({ force: true }),
213
+ snap.warm({ force: true }),
214
+ ]);
215
+ assert.equal(calls.git, 1);
216
+ assert.equal(calls.github, 1);
217
+ });
218
+
219
+ test('warm:没传 sections 就是全部;已有新鲜板块时不重复生成', async () => {
220
+ const dir = await tmpDir();
221
+ const { deps, calls } = makeDeps();
222
+ const snap = createAiContextSnapshotter({ ...deps, dir });
223
+
224
+ await snap.refreshAll({ force: true });
225
+ await snap.warm();
226
+ assert.equal(calls.git, 1, 'warm 不该在新鲜时重跑');
227
+ assert.equal(calls.github, 1);
228
+ });
229
+
230
+ test('warm:一次预热就把七个板块全采集了 —— 不依赖任何面板挂载', async () => {
231
+ // 为什么钉这条:快照采集**不能**跟 UI 挂载绑在一起。前端各 view 是 v-show 外壳 +
232
+ // KeepAlive 内 v-if 懒挂载(见 App.vue),没点过的板块组件根本不存在 —— 采集若依赖面板,
233
+ // "用户一次都没切板块 → g ai 看不到任何板块"就成了默认状态。
234
+ // 采集挂在服务端 listening 后的一次 warm() 上,所以这条保证必须成立。
235
+ const dir = await tmpDir();
236
+ const { deps, calls } = makeDeps();
237
+ // 覆盖实现时要把原来那个"计数版"接着调上,否则 calls 永远停在 0 —— 断言会误报
238
+ const baseLoadRemoteRepos = deps.loadRemoteReposState;
239
+ const snap = createAiContextSnapshotter({
240
+ ...deps,
241
+ loadRemoteReposState: async (provider) => {
242
+ await baseLoadRemoteRepos(provider);
243
+ return { installed: true, version: '1.0.0', authenticated: true, user: 'u', repos: [{ name: `${provider}-repo` }] };
244
+ },
245
+ dir,
246
+ });
247
+
248
+ await snap.warm();
249
+
250
+ const state = snap.getState();
251
+ assert.equal(state.sections.length, 7, '七个板块都要在');
252
+ for (const s of state.sections) {
253
+ assert.equal(s.ok, true, `${s.id} 没被采集 —— 没切面板就拿不到数据: ${s.summary}`);
254
+ }
255
+ assert.equal(calls.github, 1, 'github 要被拉一次');
256
+ assert.equal(calls.gitee, 1, 'gitee 要被拉一次(用户问的就是这个)');
257
+
258
+ // 文件要真落盘 —— 模型读的是文件,不只是内存缓存
259
+ for (const f of ['git-current.md', 'remote-github.md', 'remote-gitee.md', 'custom-commands.md', 'workbench-tasks.md', 'system.md', 'mindmap.md', INDEX_FILE]) {
260
+ const text = await readFile(dir, f);
261
+ assert.ok(text.length > 0, `${f} 没落盘`);
262
+ }
263
+
264
+ // 块里要带"文件在哪"的绝对路径示例:只给裸文件名时模型会拿 cwd 当基准拼错,然后回"我看不到"
265
+ const block = await snap.getBlock();
266
+ assert.match(block, /完整路径例:/);
267
+ assert.ok(block.includes(dir), '块里要出现真实的快照目录');
268
+ });
269
+
270
+ test('失败隔离:一个板块抛错不影响其它板块,且失败要写到文件与块里', async () => {
271
+ const dir = await tmpDir();
272
+ const { deps } = makeDeps({
273
+ loadRemoteReposState: async (provider) => {
274
+ if (provider === 'github') throw new Error('gh CLI 挂了');
275
+ return { installed: false };
276
+ },
277
+ });
278
+ const snap = createAiContextSnapshotter({ ...deps, dir });
279
+
280
+ const state = await snap.refreshAll({ force: true });
281
+ const github = state.sections.find(s => s.id === 'github');
282
+ assert.equal(github.ok, false);
283
+ assert.equal(state.sections.find(s => s.id === 'git').ok, true);
284
+ assert.equal(state.sections.find(s => s.id === 'gitee').ok, true);
285
+
286
+ const text = await readFile(dir, 'remote-github.md');
287
+ assert.match(text, /取数失败: gh CLI 挂了/);
288
+
289
+ const block = await snap.getBlock();
290
+ assert.match(block, /暂时取不到: gh CLI 挂了/);
291
+ assert.match(block, /当前项目 Git 状态/, '其它板块仍在块里');
292
+ });
293
+
294
+ test('失败隔离:配置读不到时不整体失败,自定义命令与思维导图降级为空', async () => {
295
+ const dir = await tmpDir();
296
+ const { deps } = makeDeps({
297
+ configManager: { loadConfig: async () => { throw new Error('config.json 损坏'); }, getRecentDirectories: async () => { throw new Error('读不到'); } },
298
+ });
299
+ const snap = createAiContextSnapshotter({ ...deps, dir });
300
+
301
+ const state = await snap.refreshAll({ force: true });
302
+ assert.equal(state.sections.length, 7);
303
+ assert.equal(state.sections.find(s => s.id === 'commands').summary, '共 0 条');
304
+ assert.equal(state.sections.find(s => s.id === 'tasks').ok, true);
305
+ });
306
+
307
+ test('陈旧文件清理:目录里不属于任何已知板块的 .md 会被删掉,非 .md 一律不碰', async () => {
308
+ const dir = await tmpDir();
309
+ await fs.writeFile(path.join(dir, 'legacy-section.md'), '上一个版本留下的板块文件');
310
+ await fs.writeFile(path.join(dir, 'notes.txt'), '用户自己的东西');
311
+
312
+ const { deps } = makeDeps();
313
+ await createAiContextSnapshotter({ ...deps, dir }).refreshAll({ force: true });
314
+
315
+ const left = (await fs.readdir(dir)).sort();
316
+ assert.ok(!left.includes('legacy-section.md'), '陈旧板块文件应被清掉');
317
+ assert.ok(left.includes('notes.txt'), '非 .md 不碰');
318
+ assert.ok(left.includes(INDEX_FILE));
319
+ });
320
+
321
+ test('invalidate:可以只失效某一个板块,其余保持已采集', async () => {
322
+ const dir = await tmpDir();
323
+ let clock = 1000;
324
+ const { deps, calls } = makeDeps({ now: () => clock });
325
+ const snap = createAiContextSnapshotter({ ...deps, dir });
326
+
327
+ await snap.refreshAll({ force: true });
328
+ assert.equal(snap.getState().sections.every(s => s.fresh), true);
329
+
330
+ snap.invalidate(['github']);
331
+ const state = snap.getState();
332
+ assert.equal(state.sections.find(s => s.id === 'github').fresh, false);
333
+ assert.equal(state.sections.find(s => s.id === 'git').fresh, true);
334
+
335
+ clock += 1;
336
+ await snap.refreshAll({ force: true });
337
+ assert.equal(calls.github, 2, '失效的板块应重跑');
338
+ });
339
+
340
+ test('invalidate:不传参数时全部失效', async () => {
341
+ const dir = await tmpDir();
342
+ const { deps } = makeDeps();
343
+ const snap = createAiContextSnapshotter({ ...deps, dir });
344
+
345
+ await snap.refreshAll({ force: true });
346
+ snap.invalidate();
347
+ assert.equal(snap.getState().sections.every(s => s.collectedAt === null), true);
348
+ assert.equal(await snap.getSnapshot(), null, '全失效后没有旧的可回');
349
+ });
350
+
351
+ /** 轮询等待某个条件成立(后台刷新是 fire-and-forget,只能等) */
352
+ async function waitFor(fn, timeoutMs = 500) {
353
+ const t0 = Date.now();
354
+ while (Date.now() - t0 < timeoutMs) {
355
+ if (fn()) return true;
356
+ await new Promise(r => setTimeout(r, 5));
357
+ }
358
+ return false;
359
+ }
360
+
361
+ test('getSectionResult:原样取某块缓存(含结构化 data),且**不**触发其它板块的后台刷新', async () => {
362
+ // 这条守的是 envContext 那条链路:派发任务时只要 tasks 板块,
363
+ // 没理由因此去拉 gh/gitee。对比 getSnapshot() —— 它会把过期的板块全推去后台刷。
364
+ const dir = await tmpDir();
365
+ let clock = 1000;
366
+ const { deps, calls } = makeDeps({ now: () => clock });
367
+ const snap = createAiContextSnapshotter({ ...deps, dir });
368
+
369
+ await snap.refreshSections(['tasks'], { force: true });
370
+ assert.equal(calls.github, 0, '只刷 tasks 不该联网');
371
+ assert.equal(calls.git, 0, '只刷 tasks 也不该探 git');
372
+
373
+ const tasks = snap.getSectionResult('tasks');
374
+ assert.ok(tasks, 'tasks 板块应当能在缓存里取到');
375
+ assert.equal(tasks.ok, true);
376
+ assert.ok(tasks.data, 'tasks 板块必须带结构化 data(envContext 的唯一数据来源)');
377
+ assert.equal(tasks.data.total.total, 1);
378
+ assert.equal(snap.getSectionResult('git'), null, '没采过的板块回 null');
379
+ assert.equal(snap.getSectionResult('不存在'), null);
380
+
381
+ // 把时间推到所有板块都过期,再调 getSectionResult:不该有任何后台刷新被触发
382
+ clock += 10 * 60_000;
383
+ snap.getSectionResult('tasks');
384
+ await new Promise(r => setTimeout(r, 20));
385
+ assert.equal(calls.git, 0, 'getSectionResult 是纯读,不该顺手补刷别的板块');
386
+ assert.equal(calls.github, 0);
387
+
388
+ // 反证:同一时刻 getSnapshot 会去后台补刷过期的板块(这正是两者的区别)
389
+ await snap.getSnapshot();
390
+ assert.ok(await waitFor(() => calls.git >= 1), 'getSnapshot 应当把过期的 git 推去后台刷新');
391
+ });
392
+
393
+ test('getState:报出每个板块的采集时间、年龄、新鲜度与摘要', async () => {
394
+ const dir = await tmpDir();
395
+ let clock = 1_000_000;
396
+ const { deps } = makeDeps({ now: () => clock });
397
+ const snap = createAiContextSnapshotter({ ...deps, dir });
398
+
399
+ await snap.refreshAll({ force: true });
400
+ clock += 5000;
401
+ const state = snap.getState();
402
+
403
+ assert.equal(state.sections.length, 7);
404
+ const git = state.sections.find(s => s.id === 'git');
405
+ assert.equal(git.file, 'git-current.md');
406
+ assert.equal(git.ageMs, 5000);
407
+ assert.equal(git.fresh, true, 'git ttl 15s,5 秒后仍新鲜');
408
+ assert.equal(git.ok, true);
409
+ assert.match(git.summary, /分支 main/);
410
+ assert.ok(git.ttlMs > 0);
411
+ assert.ok(state.persistedAt);
412
+ });
413
+
414
+ test('全部板块失败:仍然返回一份完整快照(七块都写明取不到),不是空壳', async () => {
415
+ const dir = await tmpDir();
416
+ const { deps } = makeDeps({
417
+ probeGit: async () => { throw new Error('boom'); },
418
+ loadRemoteReposState: async () => { throw new Error('boom'); },
419
+ readTasks: async () => { throw new Error('boom'); },
420
+ readJobs: async () => { throw new Error('boom'); },
421
+ getMonitorOverview: async () => { throw new Error('boom'); },
422
+ listPorts: async () => { throw new Error('boom'); },
423
+ listMindmapFiles: async () => { throw new Error('boom'); },
424
+ });
425
+ const snap = createAiContextSnapshotter({ ...deps, dir });
426
+
427
+ const state = await snap.refreshAll({ force: true });
428
+ assert.equal(state.sections.length, 7);
429
+ assert.equal(state.sections.find(s => s.id === 'git').ok, false);
430
+ // 自定义命令读的是配置(这里正常),所以它是七块里唯一成功的
431
+ assert.equal(state.sections.find(s => s.id === 'commands').ok, true);
432
+ assert.equal(state.sections.filter(s => !s.ok).length, 6);
433
+ });
@@ -0,0 +1,110 @@
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
+ // 它自己的仓库多了一条提交。而用户下一刻很可能就切到智能体页问"刚才那个任务怎么样了"。
19
+ // 没有这个触发点,那次提问拿到的还是最多 30s 前的快照(tasks 的 TTL),
20
+ // 表现为"任务明明跑完了,g ai 却说还在进行中"。
21
+ //
22
+ // 为什么挂在 jobStore 的事件总线上,而不是往 taskRunner 里塞一个 hook:
23
+ // 终态是 taskRunner 在 finally 里 publish('job:update') 推出来的,而 workbench/index.js
24
+ // 本来就在消费同一条总线(SSE 广播)。多挂一个监听,比往 1000+ 行的热模块里加一个
25
+ // setter + 一处 await 要干净得多。
26
+ //
27
+ // 为什么可以"激进地 force":四个板块里 github / gitee 要联网,但它们各自有
28
+ // forceTtlMs(60s) 地板 —— 「连续跑 20 个任务」最多每分钟真拉一次。这正是地板机制
29
+ // 存在的意义(见 aiContext/index.js 头注释第 2 条)。
30
+ //
31
+ // 本模块保持**零依赖纯逻辑**:快照生成器从外面传进来,单测不用碰真实快照。
32
+
33
+ /** job 的终态 —— 只有这三个才算"执行结束了"。running / pending 都要忽略。 */
34
+ export const TERMINAL_JOB_STATUSES = new Set(['done', 'cancelled', 'error']);
35
+
36
+ /**
37
+ * 任务结束后要刷的板块。
38
+ *
39
+ * 一个任务跑完,直接受影响的是两件事:看板状态(tasks)与它自己那个仓库的工作区(git);
40
+ * 顺手建仓 / 推远端则落到 github / gitee。四块都刷,由各自的 forceTtlMs 兜住频率。
41
+ */
42
+ export const JOB_SETTLED_SECTIONS = ['tasks', 'git', 'github', 'gitee'];
43
+
44
+ /**
45
+ * 去重集合的上限。
46
+ *
47
+ * 取消一个任务会先收到一条"立即反馈"(workbench/index.js 的 cancel 路由,status=cancelled),
48
+ * 随后 child 真正退出、taskRunner 的 finally 又推一条同状态 —— 不去重就是白刷一次。
49
+ * 键用 `${id}:${status}`,正常任务只会命中一次;集合按插入序淘汰,防止长跑服务里无限膨胀。
50
+ */
51
+ export const SEEN_TERMINAL_LIMIT = 1000;
52
+
53
+ /**
54
+ * 建一个「任务结束 → 刷快照」的监听器。
55
+ *
56
+ * @param {object} deps
57
+ * @param {Function} deps.getSnapshotter () => snapshotter|null(延迟取值:注册时它可能还没建好)
58
+ * @param {string[]} [deps.sections] 要刷的板块,默认 JOB_SETTLED_SECTIONS
59
+ * @param {Function} [deps.onError] 刷新失败时回调(默认吞掉 —— 刷不动快照不该影响任务本身)
60
+ * @returns {(evt: object) => boolean} 事件处理函数;返回 true 表示本次真的触发了刷新
61
+ */
62
+ export function createJobSettledRefresher({
63
+ getSnapshotter,
64
+ sections = JOB_SETTLED_SECTIONS,
65
+ onError,
66
+ } = {}) {
67
+ const seen = new Set();
68
+
69
+ function remember(key) {
70
+ seen.add(key);
71
+ // Set 保插入序 → 第一个就是最旧的
72
+ while (seen.size > SEEN_TERMINAL_LIMIT) {
73
+ seen.delete(seen.values().next().value);
74
+ }
75
+ }
76
+
77
+ function reportError(err) {
78
+ try { onError?.(err); } catch { /* 日志失败不能搞挂主链路 */ }
79
+ }
80
+
81
+ return function handleEvent(evt) {
82
+ if (!evt || evt.event !== 'job:update') return false;
83
+ const job = evt.payload;
84
+ if (!job || !TERMINAL_JOB_STATUSES.has(job.status)) return false;
85
+
86
+ const key = `${job.id || ''}:${job.status}`;
87
+ if (seen.has(key)) return false;
88
+ remember(key);
89
+
90
+ const snapshotter = typeof getSnapshotter === 'function' ? getSnapshotter() : null;
91
+ if (!snapshotter || typeof snapshotter.refreshSections !== 'function') return false;
92
+
93
+ // fire-and-forget:任务已经结束了,这次刷新只服务于"下一次提问",
94
+ // 绝不能反过来让路由层或者任务收尾逻辑等它(联网板块可能要几秒)。
95
+ //
96
+ // 直接调用、**只对返回的 Promise 挂 catch** —— 不用 `Promise.resolve().then(...)`
97
+ // 把调用本身推迟一个微任务:那样"刷新到底有没有启动"在事件处理函数返回时就看不出来,
98
+ // 排错时非常别扭(单测也只能靠 sleep 猜)。这里要的是"立刻启动、不等待、不冒泡"。
99
+ let pending;
100
+ try {
101
+ pending = snapshotter.refreshSections(sections, { force: true });
102
+ } catch (err) {
103
+ // 实现里同步抛(而不是返回 rejected Promise)也要吞掉
104
+ reportError(err);
105
+ return true;
106
+ }
107
+ Promise.resolve(pending).catch(reportError);
108
+ return true;
109
+ };
110
+ }