zen-gitsync 2.17.20 → 2.17.21
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 +10 -8
- package/package.json +3 -1
- package/src/ui/public/assets/AgentEngineSelector-DR3r5eQE.css +1 -0
- package/src/ui/public/assets/AgentEngineSelector-D_8VedA0.js +1 -0
- package/src/ui/public/assets/AgentView-CpKIfQLq.js +1 -0
- package/src/ui/public/assets/AgentView-DnN-vCMi.css +1 -0
- package/src/ui/public/assets/{AppVersionBadge-DDYQqoy7.css → AppVersionBadge--5If494j.css} +1 -1
- package/src/ui/public/assets/AppVersionBadge-kyk4Mmvf.js +2 -0
- package/src/ui/public/assets/{BranchSelector-F4ibldMN.css → BranchSelector-BTCMYwG0.css} +1 -1
- package/src/ui/public/assets/BranchSelector-BUPiKcdW.js +1 -0
- package/src/ui/public/assets/CommitForm-C-cL8_vz.css +1 -0
- package/src/ui/public/assets/CommitForm-CMe1gjY-.js +6 -0
- package/src/ui/public/assets/CommonDialog-Bzi5ISqT.css +1 -0
- package/src/ui/public/assets/CommonDialog-DMWtstZq.js +1 -0
- package/src/ui/public/assets/EditorView-C6msf9YP.js +1 -0
- package/src/ui/public/assets/{EditorView-BZjWMYYl.css → EditorView-mUmFBb7H.css} +1 -1
- package/src/ui/public/assets/FileDiffViewer-B6zy6NiA.css +1 -0
- package/src/ui/public/assets/FileDiffViewer-CvN5b1ZC.js +14 -0
- package/src/ui/public/assets/FlowExecutionViewer-B5kh7VxS.css +1 -0
- package/src/ui/public/assets/FlowExecutionViewer-Ynr4q8qE.js +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CM2_9TW3.css +1 -0
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DPDZc9uf.js +21 -0
- package/src/ui/public/assets/IconButton-CFJkDLVG.js +1 -0
- package/src/ui/public/assets/IconButton-W2X09OKH.css +1 -0
- package/src/ui/public/assets/LogList-C7q0Y4J_.css +1 -0
- package/src/ui/public/assets/LogList-CbfsCVsy.js +7 -0
- package/src/ui/public/assets/MindmapView-C4xg3TwX.css +1 -0
- package/src/ui/public/assets/MindmapView-D8A1V-Fd.js +1 -0
- package/src/ui/public/assets/MonitorView-BWOQfPtI.css +1 -0
- package/src/ui/public/assets/MonitorView-Bm_vUP8Y.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-BBND5XcZ.js +1 -0
- package/src/ui/public/assets/ProjectStartupButton-VoQqFee4.css +1 -0
- package/src/ui/public/assets/RecentDirectoriesChat-BQbncY1L.css +1 -0
- package/src/ui/public/assets/RecentDirectoriesChat-D9fMFh8r.js +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-BSOph2h2.css +1 -0
- package/src/ui/public/assets/RemoteManagerDialog-BVQeIr8U.js +1 -0
- package/src/ui/public/assets/RemoteRepoCard-CG9WPcA_.css +1 -0
- package/src/ui/public/assets/RemoteRepoCard-vuCu_cpA.js +1 -0
- package/src/ui/public/assets/SourceMapView-BZjgxQM8.js +3 -0
- package/src/ui/public/assets/SourceMapView-CO_BZJKu.css +1 -0
- package/src/ui/public/assets/{SvgIcon-CDNsk4-Q.css → SvgIcon-BQ6bR7l0.css} +1 -1
- package/src/ui/public/assets/SvgIcon-D0hK-p1U.js +1 -0
- package/src/ui/public/assets/TemplateManager-DiM3vrbf.css +1 -0
- package/src/ui/public/assets/TemplateManager-XoPZv_i6.js +1 -0
- package/src/ui/public/assets/UserInputNode-CZarmsrv.js +1 -0
- package/src/ui/public/assets/UserInputNode-d-oRXM_J.css +1 -0
- package/src/ui/public/assets/WorkbenchView-BLyXsdYw.css +1 -0
- package/src/ui/public/assets/WorkbenchView-CPur8WEj.js +6 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-o8uPiAjs.js → _plugin-vue_export-helper-N8Vc5eEs.js} +5 -5
- package/src/ui/public/assets/{agentConversations-BmOPFH_t.js → agentConversations-Da7zLPfP.js} +1 -1
- package/src/ui/public/assets/{configStore-DrMJDegh.js → configStore-BFb0I8eP.js} +1 -1
- package/src/ui/public/assets/element-plus-Bx3j6vzG.js +28 -0
- package/src/ui/public/assets/{flow-mindmap-C22NSZc1.js → flow-mindmap-OPE_D4da.js} +34 -34
- package/src/ui/public/assets/{gitStore-BI_ujQ9r.js → gitStore-grKwgyHH.js} +1 -1
- package/src/ui/public/assets/index-BHYAwndE.js +29 -0
- package/src/ui/public/assets/index-DVJITdth.css +1 -0
- package/src/ui/public/assets/office-docx-B0sJQnr-.js +31 -0
- package/src/ui/public/assets/{office-excel-BOjWK-1x.js → office-excel-DMRySOdK.js} +1 -1
- package/src/ui/public/assets/office-pptx-Do2blxao.js +60 -0
- package/src/ui/public/assets/previewSandbox-D4455rKO.js +9 -0
- package/src/ui/public/assets/{previewSandbox-PYnM6Paz.css → previewSandbox-SqNQvuHv.css} +1 -1
- package/src/ui/public/assets/socket-io-TBtw0msS.js +1 -0
- package/src/ui/public/assets/{toolsStore-DgqU92bI.js → toolsStore-B49mCaQ_.js} +1 -1
- package/src/ui/public/assets/{useThemeObserver-CiUpR4zj.js → useThemeObserver-DYxFoHh1.js} +1 -1
- package/src/ui/public/assets/{vendor-CPpc-p6F.js → vendor-CyWsJd6S.js} +45 -52
- package/src/ui/public/assets/vue-flow-fVVGZspa.js +39 -0
- package/src/ui/public/index.html +24 -24
- package/src/ui/server/index.js +8 -0
- package/src/ui/server/routes/workbench/index.js +156 -7
- package/src/ui/server/routes/workbench/jobStore.js +141 -48
- package/src/ui/server/routes/workbench/jobStore.test.js +139 -2
- package/src/ui/server/routes/workbench/liveJobs.js +226 -0
- package/src/ui/server/routes/workbench/liveJobs.test.js +215 -0
- package/src/ui/server/routes/workbench/orchestratorStore.js +113 -2
- package/src/ui/server/routes/workbench/progressReport.js +293 -0
- package/src/ui/server/routes/workbench/progressReport.test.js +344 -0
- package/src/ui/server/routes/workbench/shared.js +28 -0
- package/src/ui/server/routes/workbench/taskRunner.js +8 -0
- package/src/ui/public/assets/AgentEngineSelector-DIn2efUS.js +0 -2
- package/src/ui/public/assets/AgentEngineSelector-DUf0TfaU.css +0 -1
- package/src/ui/public/assets/AgentView-CS-sFe-x.js +0 -10
- package/src/ui/public/assets/AgentView-CWCc_b0o.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-CeHsanRP.js +0 -2
- package/src/ui/public/assets/BranchSelector-CxEoHp2q.js +0 -1
- package/src/ui/public/assets/CommitForm-4kI2csgs.css +0 -1
- package/src/ui/public/assets/CommitForm-DOfYuDqr.js +0 -6
- package/src/ui/public/assets/CommonDialog-CZ5McwHT.js +0 -1
- package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +0 -1
- package/src/ui/public/assets/EditorView-BxRrHGUj.js +0 -31
- package/src/ui/public/assets/FileDiffViewer-CKcSkMAd.js +0 -15
- package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-DShoSVO7.js +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-BkPZZ0-D.js +0 -21
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +0 -1
- package/src/ui/public/assets/IconButton-D-maDWg7.js +0 -1
- package/src/ui/public/assets/IconButton-RlXqKOeu.css +0 -1
- package/src/ui/public/assets/LogList-Bv5ZqdMf.js +0 -7
- package/src/ui/public/assets/LogList-XspUhx-C.css +0 -1
- package/src/ui/public/assets/MindmapView-C27U_5_C.js +0 -2
- package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +0 -1
- package/src/ui/public/assets/MonitorView-BudY0fTU.js +0 -1
- package/src/ui/public/assets/MonitorView-DwUwJXnY.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-CB8IOg1E.css +0 -1
- package/src/ui/public/assets/ProjectStartupButton-YJPcfS3t.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-Bqd_4eU5.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-CZD_IC5M.css +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-BUNLKNem.js +0 -4
- package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +0 -1
- package/src/ui/public/assets/RemoteRepoCard-Bq1oLUnZ.js +0 -7
- package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +0 -1
- package/src/ui/public/assets/SourceMapView-BCrE4CvT.js +0 -3
- package/src/ui/public/assets/SourceMapView-CfDydtsu.css +0 -1
- package/src/ui/public/assets/SvgIcon-z4ExzvGR.js +0 -1
- package/src/ui/public/assets/TemplateManager-BSq-dNvW.js +0 -1
- package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +0 -1
- package/src/ui/public/assets/UserInputNode-BzbaR9lE.js +0 -1
- package/src/ui/public/assets/UserInputNode-DDNSarag.css +0 -1
- package/src/ui/public/assets/WorkbenchView-BJ7lMt3C.js +0 -70
- package/src/ui/public/assets/WorkbenchView-DGIn0UR4.css +0 -1
- package/src/ui/public/assets/element-plus-AaVXaSqv.js +0 -48
- package/src/ui/public/assets/index-Cp5WtCfY.css +0 -1
- package/src/ui/public/assets/index-D_Pwvey4.js +0 -130
- package/src/ui/public/assets/office-docx-CJ6JSCs6.js +0 -32
- package/src/ui/public/assets/office-pptx-CF43ZwXE.js +0 -74
- package/src/ui/public/assets/previewSandbox-B16xvZLS.js +0 -9
- package/src/ui/public/assets/socket-io-BFqQXqAp.js +0 -1
- package/src/ui/public/assets/vue-flow-DwYB6Myr.js +0 -39
|
@@ -60,15 +60,21 @@ after(async () => {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
const modUrl = (rel) => pathToFileURL(path.join(projectRoot, rel)).href;
|
|
63
|
-
const { DATA_DIR } = await import(modUrl('src/ui/server/routes/workbench/shared.js'));
|
|
63
|
+
const { DATA_DIR, LIVE_JOBS_DIR } = await import(modUrl('src/ui/server/routes/workbench/shared.js'));
|
|
64
64
|
const {
|
|
65
65
|
jobs,
|
|
66
66
|
snapshotJobs,
|
|
67
67
|
mergedJobs,
|
|
68
68
|
refreshJobsFromDisk,
|
|
69
69
|
flushJobsSaveNow,
|
|
70
|
+
flushActiveJobsSave,
|
|
70
71
|
} = await import(modUrl('src/ui/server/routes/workbench/jobStore.js'));
|
|
71
|
-
const {
|
|
72
|
+
const {
|
|
73
|
+
writeLiveJobsFile,
|
|
74
|
+
toLiveRecord,
|
|
75
|
+
liveJobsFilePath,
|
|
76
|
+
} = await import(modUrl('src/ui/server/routes/workbench/liveJobs.js'));
|
|
77
|
+
const { deriveTaskColumn, groupJobsByTask, decorateTaskForBoard } = await import(modUrl('src/ui/server/routes/workbench/projectRegistry.js'));
|
|
72
78
|
|
|
73
79
|
const dataDir = path.join(fakeHome, '.zen-gitsync');
|
|
74
80
|
const jobsFile = path.join(dataDir, 'jobs.json');
|
|
@@ -215,3 +221,134 @@ test('落盘不抹别人的记录:flush 必须并上磁盘上的历史 job 再
|
|
|
215
221
|
jobs.delete('j-mine');
|
|
216
222
|
}
|
|
217
223
|
});
|
|
224
|
+
|
|
225
|
+
// ── 运行中(live-jobs):别的实例正在跑的任务 ─────────────────────────
|
|
226
|
+
// 2026-09-28:终态以前才是唯一落盘点,运行中的 job 只活在跑它的进程内存里 ——
|
|
227
|
+
// 于是「已完成」两边一致,「进行中」只有跑的那个实例看得到。这一组钉住修复后的口径:
|
|
228
|
+
// 内存 > live > 磁盘,且终态 flush 之后 live 文件必须立刻消失。
|
|
229
|
+
|
|
230
|
+
/** 一个"别的实例正在跑"的 job。pid 用假值,只影响 taskRunner 那边,读者不看它。 */
|
|
231
|
+
const liveJob = (id, taskId, extra = {}) => ({
|
|
232
|
+
id,
|
|
233
|
+
taskId,
|
|
234
|
+
subId: `${taskId}__simple`,
|
|
235
|
+
title: `job-${id}`,
|
|
236
|
+
status: 'running',
|
|
237
|
+
startedAt: '2026-09-28T14:56:51.650Z',
|
|
238
|
+
endedAt: null,
|
|
239
|
+
pid: 4242,
|
|
240
|
+
agent: 'claude',
|
|
241
|
+
prompt: 'p',
|
|
242
|
+
output: 'partial output',
|
|
243
|
+
thinking: '',
|
|
244
|
+
toolCalls: [],
|
|
245
|
+
claudeSessionId: null,
|
|
246
|
+
...extra,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
/** 每个用例从空的 live 目录 + 空的 jobs.json 开始,免得上一轮的 owner 文件串味 */
|
|
250
|
+
async function resetStores() {
|
|
251
|
+
jobs.clear();
|
|
252
|
+
await fs.rm(LIVE_JOBS_DIR, { recursive: true, force: true });
|
|
253
|
+
await seedJobsFile([]);
|
|
254
|
+
await refreshJobsFromDisk({ force: true });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 写一份"别的实例"的 live 文件。ownerPid 用**本测试进程**的 pid:读者靠
|
|
259
|
+
* process.kill(pid, 0) 判存活,只有真实存在的 pid 才会被采信(见 liveJobs.js)。
|
|
260
|
+
*/
|
|
261
|
+
async function seedLiveFile(records, ownerPid = process.pid) {
|
|
262
|
+
await writeLiveJobsFile(ownerPid, records.map(r => toLiveRecord(r)));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
test('另一个实例正在跑的任务:刷新后必须推导成「进行中」', async () => {
|
|
266
|
+
await resetStores();
|
|
267
|
+
await seedLiveFile([liveJob('j-run', 't1')]);
|
|
268
|
+
await refreshJobsFromDisk({ force: true });
|
|
269
|
+
|
|
270
|
+
const snap = snapshotJobs();
|
|
271
|
+
assert.ok(snap.some(j => j.id === 'j-run'), '运行中的记录必须出现在快照里');
|
|
272
|
+
assert.equal(snap.find(j => j.id === 'j-run').status, 'running');
|
|
273
|
+
|
|
274
|
+
const byTask = groupJobsByTask(snap);
|
|
275
|
+
// 修复前这里是 todo —— 运行中的 job 根本没落盘,jobs.json 里查不到
|
|
276
|
+
assert.equal(deriveTaskColumn(simpleTask('t1'), byTask.get('t1') || []), 'doing');
|
|
277
|
+
assert.equal(deriveTaskColumn(simpleTask('t2'), byTask.get('t2') || []), 'todo');
|
|
278
|
+
// 看板卡片上的"进行中"计数也来自同一份快照
|
|
279
|
+
assert.equal(decorateTaskForBoard(simpleTask('t1'), byTask.get('t1') || []).runningJobs, 1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('live 覆盖磁盘:同一条历史 + 新一轮正在跑,以「进行中」为准', async () => {
|
|
283
|
+
await resetStores();
|
|
284
|
+
await seedJobsFile([diskJob('j-run', 't1', 'done')]);
|
|
285
|
+
await seedLiveFile([liveJob('j-run', 't1')]);
|
|
286
|
+
await refreshJobsFromDisk({ force: true });
|
|
287
|
+
|
|
288
|
+
assert.equal(
|
|
289
|
+
mergedJobs().get('j-run').status,
|
|
290
|
+
'running',
|
|
291
|
+
'磁盘上那条是上一轮的终态,不能盖住"这一轮正在跑"'
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('内存 > live:本进程跑的 job 用内存里的那份(带流式输出),不用广播文件里截过的', async () => {
|
|
296
|
+
await resetStores();
|
|
297
|
+
await seedLiveFile([liveJob('j-run', 't1', { output: '截断过的尾巴' })]);
|
|
298
|
+
jobs.set('j-run', {
|
|
299
|
+
id: 'j-run',
|
|
300
|
+
taskId: 't1',
|
|
301
|
+
subId: '',
|
|
302
|
+
title: 'job-j-run',
|
|
303
|
+
status: 'running',
|
|
304
|
+
pid: process.pid,
|
|
305
|
+
output: '本进程完整的输出',
|
|
306
|
+
});
|
|
307
|
+
await refreshJobsFromDisk({ force: true });
|
|
308
|
+
|
|
309
|
+
assert.equal(mergedJobs().get('j-run').output, '本进程完整的输出');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('owner 进程没了:它的运行中记录不再出现在快照里', async () => {
|
|
313
|
+
await resetStores();
|
|
314
|
+
// 2147483647 = 一定不存在的 pid
|
|
315
|
+
await seedLiveFile([liveJob('j-ghost', 't1')], 2147483647);
|
|
316
|
+
await refreshJobsFromDisk({ force: true });
|
|
317
|
+
|
|
318
|
+
assert.equal(snapshotJobs().some(j => j.id === 'j-ghost'), false);
|
|
319
|
+
assert.equal(deriveTaskColumn(simpleTask('t1'), []), 'todo');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('终态收口:job 跑完后自己那份 live 文件必须消失,读者看到的是 done', async () => {
|
|
323
|
+
await resetStores();
|
|
324
|
+
jobs.set('j-run', {
|
|
325
|
+
id: 'j-run',
|
|
326
|
+
taskId: 't1',
|
|
327
|
+
subId: '',
|
|
328
|
+
title: 'job-j-run',
|
|
329
|
+
status: 'running',
|
|
330
|
+
startedAt: '2026-09-28T14:56:51.650Z',
|
|
331
|
+
pid: process.pid,
|
|
332
|
+
output: '跑着呢',
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// 1) 运行中:文件里有它
|
|
336
|
+
await flushActiveJobsSave();
|
|
337
|
+
const raw = JSON.parse(await fs.readFile(liveJobsFilePath(process.pid), 'utf-8'));
|
|
338
|
+
assert.deepEqual(raw.jobs.map(j => j.id), ['j-run']);
|
|
339
|
+
|
|
340
|
+
// 2) 终态:flush 之后文件必须删掉(留一个空壳会让别的实例白扫,留着 running 会一直转圈)
|
|
341
|
+
jobs.get('j-run').status = 'done';
|
|
342
|
+
jobs.get('j-run').endedAt = '2026-09-28T15:00:00.000Z';
|
|
343
|
+
await flushJobsSaveNow();
|
|
344
|
+
await assert.rejects(
|
|
345
|
+
fs.stat(liveJobsFilePath(process.pid)),
|
|
346
|
+
(err) => err.code === 'ENOENT',
|
|
347
|
+
'没有活跃 job 了就不该再留 live 文件'
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// 3) 别的实例这时候读到的是 jobs.json 里的终态
|
|
351
|
+
await refreshJobsFromDisk({ force: true });
|
|
352
|
+
assert.equal(snapshotJobs().find(j => j.id === 'j-run').status, 'done');
|
|
353
|
+
jobs.clear();
|
|
354
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
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 的跨实例可见性:每进程一个 ~/.zen-gitsync/live-jobs/<pid>.json。
|
|
16
|
+
//
|
|
17
|
+
// ── 要解决的问题(2026-09-28) ────────────────────────────────
|
|
18
|
+
// 看板的「进行中」列由执行记录推导(projectRegistry.deriveTaskColumn),
|
|
19
|
+
// 而执行记录只有**终态**才会落进 jobs.json(taskRunner 结束时才 flush)。
|
|
20
|
+
// 于是同时开两个 g ui 时:
|
|
21
|
+
// · 已完成 / 已失败 —— 两边一致(终态在 jobs.json 里,谁都能读到)
|
|
22
|
+
// · 进行中 —— 只有**跑它的那个**实例显示"进行中",其它实例的看板上那几条还挂在
|
|
23
|
+
// 「待处理」。实测复现:A(4114) 正在跑的任务,B(5966) 的 /api/workbench/projects
|
|
24
|
+
// 里该任务 column=todo、runningJobs=0,A 那边 column=doing、runningJobs=1。
|
|
25
|
+
// 根因不是"没读",而是"没写":jobStore.js 里的 scheduleJobsSave() 是死代码,
|
|
26
|
+
// 全仓没有任何调用点 —— 运行中的 job 从头到尾只活在跑它的那个进程内存里。
|
|
27
|
+
// (同文件 index.js 的 cancel 路由早就写了"这个任务正在另一个 g ui 实例里执行"
|
|
28
|
+
// 的友好提示,说明"跨实例可见"本来就是设计意图,只是写盘这一环断了。)
|
|
29
|
+
//
|
|
30
|
+
// ── 为什么是目录 + 每进程一个文件 ─────────────────────────────
|
|
31
|
+
// 直接让每条 chunk 都去写 jobs.json 会把历史档案整份重写(本机 7.5MB / 48 条),
|
|
32
|
+
// 1.5s 一次,还要连带跑一遍保留策略 —— 搬砖量全花在"同步一条状态"上,且阻塞
|
|
33
|
+
// 事件循环反过来卡住流式输出。这里只写运行中那几条,几十 KB 封顶。
|
|
34
|
+
// 目录形态则沿用 instances/ 的结论:各写各的文件,天然没有 read-modify-write
|
|
35
|
+
// 丢更新,也不需要在 Windows 上做跨进程锁。
|
|
36
|
+
//
|
|
37
|
+
// ── 生命周期 ─────────────────────────────────────────────────
|
|
38
|
+
// 写:job 转 running/pending 时、以及流式 chunk 到达时,1.5s 防抖写一次
|
|
39
|
+
// 删:本进程再没有 running/pending 的 job 时(终态 flush 会立刻走一次)
|
|
40
|
+
// 读:遍历目录,**跳过 owner 进程已消失的文件**(顺手删掉,自愈),
|
|
41
|
+
// 坏文件跳过不抛(与 instanceRegistry 同口径)
|
|
42
|
+
// 所以进程被 kill -9 也不会留下幽灵"进行中":下次别人读的时候 owner pid 已经不在了。
|
|
43
|
+
//
|
|
44
|
+
// 重要口径:**本进程内存里的 job 永远优先**(见 jobStore.mergedJobs)。这里的
|
|
45
|
+
// output/thinking/toolCalls 是**截尾**的(远端只看个大概,不需要搬全文),
|
|
46
|
+
// 本进程自己看的始终是内存里那份完整的。
|
|
47
|
+
|
|
48
|
+
import fsp from 'fs/promises';
|
|
49
|
+
import path from 'path';
|
|
50
|
+
import { logger, LIVE_JOBS_DIR } from './shared.js';
|
|
51
|
+
|
|
52
|
+
/** 远端可见的输出尾巴长度。整份日志动辄几十万字符,跨实例只需要"跑到哪了" */
|
|
53
|
+
export const LIVE_OUTPUT_TAIL_CHARS = 32 * 1024;
|
|
54
|
+
export const LIVE_THINKING_TAIL_CHARS = 8 * 1024;
|
|
55
|
+
/** 工具调用只留最近若干条;单条字段再做一次截断 */
|
|
56
|
+
export const LIVE_TOOL_CALL_LIMIT = 20;
|
|
57
|
+
export const LIVE_TOOL_CALL_FIELD_CHARS = 2 * 1024;
|
|
58
|
+
/**
|
|
59
|
+
* owner 进程还活着、但文件太久没更新时不再采信。
|
|
60
|
+
* 触发条件很苛刻(只有 pid 被复用才会出现),作用是给"幽灵进行中"一个硬上限,
|
|
61
|
+
* 而不是给正常任务设超时 —— 模型闷头想 20 分钟不吐一个 chunk 是正常的。
|
|
62
|
+
*/
|
|
63
|
+
export const LIVE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
/** 信号 0 探测存活。任何报错都当"进程不在了"——与 jobStore/taskRunner 同一口径 */
|
|
66
|
+
function defaultIsProcessAlive(pid) {
|
|
67
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
68
|
+
try { process.kill(pid, 0); return true; }
|
|
69
|
+
catch { return false; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* job → 可序列化投影。**全仓唯一一份**:SSE 快照(jobStore.snapshotJobs)与
|
|
74
|
+
* live-jobs 落盘都用它 —— 两处各写一份字段清单时,加一个字段漏掉一处不会报错,
|
|
75
|
+
* 只会让"本实例看得到、别的实例看不到"。
|
|
76
|
+
*
|
|
77
|
+
* 白名单投影:未登记字段静默剥掉(child 是 ChildProcess,不可序列化,必须剥)。
|
|
78
|
+
*/
|
|
79
|
+
export function projectJob(j) {
|
|
80
|
+
return {
|
|
81
|
+
id: j.id,
|
|
82
|
+
taskId: j.taskId,
|
|
83
|
+
subId: j.subId,
|
|
84
|
+
title: j.title,
|
|
85
|
+
status: j.status,
|
|
86
|
+
prompt: j.prompt || '',
|
|
87
|
+
output: j.output || '',
|
|
88
|
+
thinking: j.thinking || '',
|
|
89
|
+
pid: j.pid || null,
|
|
90
|
+
startedAt: j.startedAt || null,
|
|
91
|
+
endedAt: j.endedAt || null,
|
|
92
|
+
exitCode: typeof j.exitCode === 'number' ? j.exitCode : null,
|
|
93
|
+
error: j.error || null,
|
|
94
|
+
// 本轮用的执行器(claude | opencode)。前端对话区助手名 / 日志详情按它显示。
|
|
95
|
+
agent: j.agent || null,
|
|
96
|
+
// 工具调用流水({id,name,argsPreview,arguments,result,status,error}[])。
|
|
97
|
+
// 前端靠它画工具块;刷新页面 / 换实例后仍然可见,所以必须过白名单。
|
|
98
|
+
toolCalls: Array.isArray(j.toolCalls) ? j.toolCalls : [],
|
|
99
|
+
// 续接对话用:claude --output-format stream-json 的 system.init 事件捕获到的 session_id
|
|
100
|
+
claudeSessionId: j.claudeSessionId || null
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 取尾部 N 个字符(截断方向与 taskRunner 的 MAX_OUTPUT 尾部保底一致) */
|
|
105
|
+
function tail(text, max) {
|
|
106
|
+
const s = typeof text === 'string' ? text : '';
|
|
107
|
+
return s.length > max ? s.slice(-max) : s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 落盘前的瘦身:正文/思考/工具流水截尾,其余字段原样 */
|
|
111
|
+
export function toLiveRecord(job) {
|
|
112
|
+
const rec = projectJob(job);
|
|
113
|
+
rec.output = tail(rec.output, LIVE_OUTPUT_TAIL_CHARS);
|
|
114
|
+
rec.thinking = tail(rec.thinking, LIVE_THINKING_TAIL_CHARS);
|
|
115
|
+
rec.toolCalls = rec.toolCalls.slice(-LIVE_TOOL_CALL_LIMIT).map((c) => ({
|
|
116
|
+
id: c && c.id,
|
|
117
|
+
name: c && c.name,
|
|
118
|
+
argsPreview: tail(c && c.argsPreview, LIVE_TOOL_CALL_FIELD_CHARS),
|
|
119
|
+
arguments: tail(c && c.arguments, LIVE_TOOL_CALL_FIELD_CHARS),
|
|
120
|
+
result: tail(c && c.result, LIVE_TOOL_CALL_FIELD_CHARS),
|
|
121
|
+
status: c && c.status,
|
|
122
|
+
error: (c && c.error) || null
|
|
123
|
+
}));
|
|
124
|
+
return rec;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function liveJobsFilePath(ownerPid) {
|
|
128
|
+
return path.join(LIVE_JOBS_DIR, `${ownerPid}.json`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 写自己的那份。records 为空 = 本进程没有运行中的 job,直接删文件 */
|
|
132
|
+
export async function writeLiveJobsFile(ownerPid, records) {
|
|
133
|
+
if (!Number.isInteger(ownerPid) || ownerPid <= 0) throw new Error('writeLiveJobsFile: ownerPid 非法');
|
|
134
|
+
const list = Array.isArray(records) ? records : [];
|
|
135
|
+
if (list.length === 0) return removeLiveJobsFile(ownerPid);
|
|
136
|
+
await fsp.mkdir(LIVE_JOBS_DIR, { recursive: true });
|
|
137
|
+
const file = liveJobsFilePath(ownerPid);
|
|
138
|
+
const tmp = `${file}.tmp`;
|
|
139
|
+
// 原子写:tmp + rename(与 readJson/writeJson 同口径)。tmp 文件名带 pid 后缀
|
|
140
|
+
// 天然不撞车,读者按 /^\d+\.json$/ 过滤也就不会读到半截文件。
|
|
141
|
+
await fsp.writeFile(tmp, JSON.stringify({
|
|
142
|
+
version: 1,
|
|
143
|
+
ownerPid,
|
|
144
|
+
updatedAt: Date.now(),
|
|
145
|
+
jobs: list
|
|
146
|
+
}), 'utf-8');
|
|
147
|
+
await fsp.rename(tmp, file);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 删自己的那份。失败只记 warn —— 读者那边还有"owner pid 已死就跳过"兜底 */
|
|
151
|
+
export async function removeLiveJobsFile(ownerPid) {
|
|
152
|
+
try {
|
|
153
|
+
await fsp.unlink(liveJobsFilePath(ownerPid));
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err && err.code === 'ENOENT') return;
|
|
156
|
+
logger.warn(`[workbench] 清理 live-jobs/${ownerPid}.json 失败: ${err?.message || err}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// entry 文件名匹配:<pid>.json(pid 是正整数)。跳过 .tmp 等写入中间态
|
|
161
|
+
function isEntryFileName(name) {
|
|
162
|
+
return typeof name === 'string' && /^\d+\.json$/.test(name);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 扫描目录,返回所有**别的实例**正在跑的 job。
|
|
167
|
+
*
|
|
168
|
+
* 返回 `signature` 是为了让调用方(jobStore)能像 jobs.json 那样用
|
|
169
|
+
* mtime+size 做短路:看板 5s 轮询一次,目录没变化就不该真去 parse。
|
|
170
|
+
*
|
|
171
|
+
* @returns {Promise<{ signature: string, jobs: object[] }>}
|
|
172
|
+
*/
|
|
173
|
+
export async function scanLiveJobsDir({ isProcessAlive = defaultIsProcessAlive, now = Date.now() } = {}) {
|
|
174
|
+
let names;
|
|
175
|
+
try {
|
|
176
|
+
names = await fsp.readdir(LIVE_JOBS_DIR);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
if (err && err.code === 'ENOENT') return { signature: '', jobs: [] };
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const files = [];
|
|
183
|
+
for (const name of names) {
|
|
184
|
+
if (!isEntryFileName(name)) continue;
|
|
185
|
+
const fp = path.join(LIVE_JOBS_DIR, name);
|
|
186
|
+
try {
|
|
187
|
+
const st = await fsp.stat(fp);
|
|
188
|
+
files.push({ name, fp, size: st.size, mtimeMs: st.mtimeMs });
|
|
189
|
+
} catch { /* 刚被删掉,跳过 */ }
|
|
190
|
+
}
|
|
191
|
+
files.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
192
|
+
const signature = files.map(f => `${f.name}:${f.mtimeMs}:${f.size}`).join('|');
|
|
193
|
+
|
|
194
|
+
const jobs = [];
|
|
195
|
+
for (const f of files) {
|
|
196
|
+
const ownerPid = Number(f.name.slice(0, -'.json'.length));
|
|
197
|
+
// owner 不在了 —— 它跑到一半的 job 永远不会再有下文(连终态都没落进 jobs.json),
|
|
198
|
+
// 顺手把文件删掉自愈,别让别的实例的看板永远挂着"进行中"。
|
|
199
|
+
if (!isProcessAlive(ownerPid)) {
|
|
200
|
+
await removeLiveJobsFile(ownerPid);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
let data;
|
|
204
|
+
try {
|
|
205
|
+
data = JSON.parse(await fsp.readFile(f.fp, 'utf-8'));
|
|
206
|
+
} catch (err) {
|
|
207
|
+
logger.warn(`[workbench] live-jobs/${f.name} 读取失败,跳过: ${err?.message || err}`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!data || data.ownerPid !== ownerPid || !Array.isArray(data.jobs)) continue;
|
|
211
|
+
if (now - Number(data.updatedAt || 0) > LIVE_MAX_AGE_MS) continue;
|
|
212
|
+
for (const j of data.jobs) {
|
|
213
|
+
if (j && j.id) jobs.push(j);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { signature, jobs };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const liveJobs = {
|
|
220
|
+
projectJob,
|
|
221
|
+
toLiveRecord,
|
|
222
|
+
writeLiveJobsFile,
|
|
223
|
+
removeLiveJobsFile,
|
|
224
|
+
scanLiveJobsDir,
|
|
225
|
+
liveJobsFilePath,
|
|
226
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
// 「运行中 job 的跨实例广播」(live-jobs/<pid>.json) 的回归测试(2026-09-28)。
|
|
17
|
+
//
|
|
18
|
+
// 背景:jobStore 里的 scheduleJobsSave() 一直是死代码,运行中的 job 从头到尾只活在
|
|
19
|
+
// 跑它的那个进程内存里 —— 于是同机开两个 g ui 时,「已完成」两边一致,「进行中」
|
|
20
|
+
// 只有跑的那个实例看得到。修法见 liveJobs.js 文件头。本文件钉住它的四条口径:
|
|
21
|
+
// 1. 写进去的活跃记录,别的实例扫得到
|
|
22
|
+
// 2. owner 进程没了 → 跳过并自愈删文件(不留幽灵「进行中」)
|
|
23
|
+
// 3. 没有活跃 job → 文件直接删掉(不能留个空壳让读者白扫)
|
|
24
|
+
// 4. 正文/思考/工具流水**截尾**,别让一份跑了几小时的任务把广播文件撑成 MB 级
|
|
25
|
+
//
|
|
26
|
+
// 隔离策略与 jobStore.test.js 一致:在 import 之前把 USERPROFILE/HOME 指向 mkdtemp。
|
|
27
|
+
|
|
28
|
+
import { test, after, beforeEach } 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 liveJobs.js 之前完成 ----
|
|
39
|
+
|
|
40
|
+
const fakeHome = await fs.mkdtemp(path.join(os.tmpdir(), 'zgs-livejobs-test-'));
|
|
41
|
+
const savedEnv = {
|
|
42
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
43
|
+
HOME: process.env.HOME,
|
|
44
|
+
HOMEDRIVE: process.env.HOMEDRIVE,
|
|
45
|
+
HOMEPATH: process.env.HOMEPATH,
|
|
46
|
+
};
|
|
47
|
+
process.env.USERPROFILE = fakeHome;
|
|
48
|
+
process.env.HOME = fakeHome;
|
|
49
|
+
delete process.env.HOMEDRIVE;
|
|
50
|
+
delete process.env.HOMEPATH;
|
|
51
|
+
|
|
52
|
+
after(async () => {
|
|
53
|
+
for (const [k, v] of Object.entries(savedEnv)) {
|
|
54
|
+
if (v === undefined) delete process.env[k];
|
|
55
|
+
else process.env[k] = v;
|
|
56
|
+
}
|
|
57
|
+
try { await fs.rm(fakeHome, { recursive: true, force: true }) } catch { /* 清理失败不影响结论 */ }
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const modUrl = (rel) => pathToFileURL(path.join(projectRoot, rel)).href;
|
|
61
|
+
const {
|
|
62
|
+
LIVE_JOBS_DIR,
|
|
63
|
+
} = await import(modUrl('src/ui/server/routes/workbench/shared.js'));
|
|
64
|
+
const {
|
|
65
|
+
projectJob,
|
|
66
|
+
toLiveRecord,
|
|
67
|
+
writeLiveJobsFile,
|
|
68
|
+
removeLiveJobsFile,
|
|
69
|
+
scanLiveJobsDir,
|
|
70
|
+
liveJobsFilePath,
|
|
71
|
+
LIVE_OUTPUT_TAIL_CHARS,
|
|
72
|
+
LIVE_THINKING_TAIL_CHARS,
|
|
73
|
+
LIVE_TOOL_CALL_LIMIT,
|
|
74
|
+
} = await import(modUrl('src/ui/server/routes/workbench/liveJobs.js'));
|
|
75
|
+
|
|
76
|
+
/** 一个"正在跑"的 job。字段照 taskRunner 里那个对象形状给。 */
|
|
77
|
+
const runningJob = (id, extra = {}) => ({
|
|
78
|
+
id,
|
|
79
|
+
taskId: 't1',
|
|
80
|
+
subId: 't1__simple',
|
|
81
|
+
title: `job-${id}`,
|
|
82
|
+
status: 'running',
|
|
83
|
+
pid: 4242,
|
|
84
|
+
startedAt: '2026-09-28T14:56:51.650Z',
|
|
85
|
+
endedAt: null,
|
|
86
|
+
agent: 'claude',
|
|
87
|
+
prompt: 'p',
|
|
88
|
+
output: 'out',
|
|
89
|
+
thinking: 'think',
|
|
90
|
+
toolCalls: [],
|
|
91
|
+
claudeSessionId: null,
|
|
92
|
+
// 不该被广播出去的内部字段(白名单投影必须剥掉)
|
|
93
|
+
child: { pid: 4242 },
|
|
94
|
+
...extra,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// 每个用例从干净的目录开始:上一轮的 owner 文件会让 scan 结果不可预期
|
|
98
|
+
beforeEach(async () => {
|
|
99
|
+
await fs.rm(LIVE_JOBS_DIR, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ── 沙箱自检 ────────────────────────────────────────────────────────
|
|
103
|
+
test('沙箱:live-jobs 目录落在 mkdtemp 里,不是用户真实 home', () => {
|
|
104
|
+
assert.ok(LIVE_JOBS_DIR.startsWith(fakeHome), `LIVE_JOBS_DIR 应落在沙箱内,实际: ${LIVE_JOBS_DIR}`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── 投影 ────────────────────────────────────────────────────────────
|
|
108
|
+
test('projectJob:白名单投影剥掉 child 等不可序列化字段,保留前端要的字段', () => {
|
|
109
|
+
const rec = projectJob(runningJob('j1'));
|
|
110
|
+
assert.equal(rec.id, 'j1');
|
|
111
|
+
assert.equal(rec.status, 'running');
|
|
112
|
+
assert.equal(rec.agent, 'claude');
|
|
113
|
+
assert.equal(rec.claudeSessionId, null);
|
|
114
|
+
assert.deepEqual(rec.toolCalls, []);
|
|
115
|
+
assert.ok(!('child' in rec), 'child 必须先剥掉,否则 JSON.stringify 直接抛');
|
|
116
|
+
// 投影结果要能真的序列化
|
|
117
|
+
assert.doesNotThrow(() => JSON.stringify(rec));
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('toLiveRecord:正文/思考截尾,工具流水只留最近若干条', () => {
|
|
121
|
+
const toolCalls = Array.from({ length: LIVE_TOOL_CALL_LIMIT + 5 }, (_, i) => ({
|
|
122
|
+
id: `c${i}`,
|
|
123
|
+
name: 'Bash',
|
|
124
|
+
argsPreview: 'a'.repeat(10),
|
|
125
|
+
arguments: 'b'.repeat(5000),
|
|
126
|
+
result: 'r'.repeat(5000),
|
|
127
|
+
status: 'completed',
|
|
128
|
+
error: null,
|
|
129
|
+
}));
|
|
130
|
+
const rec = toLiveRecord(runningJob('j2', {
|
|
131
|
+
output: 'o'.repeat(LIVE_OUTPUT_TAIL_CHARS + 1000) + 'TAIL',
|
|
132
|
+
thinking: 't'.repeat(LIVE_THINKING_TAIL_CHARS + 500),
|
|
133
|
+
toolCalls,
|
|
134
|
+
}));
|
|
135
|
+
assert.equal(rec.output.length, LIVE_OUTPUT_TAIL_CHARS);
|
|
136
|
+
assert.ok(rec.output.endsWith('TAIL'), '截的必须是尾巴(最新的那段)');
|
|
137
|
+
assert.equal(rec.thinking.length, LIVE_THINKING_TAIL_CHARS);
|
|
138
|
+
assert.equal(rec.toolCalls.length, LIVE_TOOL_CALL_LIMIT);
|
|
139
|
+
// 留下的是最近的:最后一条 id 应等于原数组最后一条
|
|
140
|
+
assert.equal(rec.toolCalls.at(-1).id, `c${LIVE_TOOL_CALL_LIMIT + 4}`);
|
|
141
|
+
assert.ok(rec.toolCalls[0].arguments.length <= 2 * 1024);
|
|
142
|
+
assert.ok(rec.toolCalls[0].result.length <= 2 * 1024);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── 写 / 读 ─────────────────────────────────────────────────────────
|
|
146
|
+
test('写进去的活跃记录:别的实例扫得到,且字段完整', async () => {
|
|
147
|
+
await writeLiveJobsFile(4242, [toLiveRecord(runningJob('j-live'))]);
|
|
148
|
+
const { signature, jobs: found } = await scanLiveJobsDir({ isProcessAlive: () => true });
|
|
149
|
+
assert.ok(signature.length > 0, 'signature 非空才能让读者用 mtime+size 短路');
|
|
150
|
+
assert.equal(found.length, 1);
|
|
151
|
+
assert.equal(found[0].id, 'j-live');
|
|
152
|
+
assert.equal(found[0].status, 'running');
|
|
153
|
+
assert.equal(found[0].taskId, 't1');
|
|
154
|
+
assert.ok(!('child' in found[0]));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('owner 进程已消失:跳过该文件并顺手删掉(不留幽灵「进行中」)', async () => {
|
|
158
|
+
// 2147483647 是不存在的 pid(沿用 jobStore.test.js 的取法),这条分支不依赖机器状态
|
|
159
|
+
await writeLiveJobsFile(2147483647, [toLiveRecord(runningJob('j-ghost'))]);
|
|
160
|
+
const { jobs: found } = await scanLiveJobsDir();
|
|
161
|
+
assert.equal(found.length, 0);
|
|
162
|
+
await assert.rejects(
|
|
163
|
+
fs.stat(liveJobsFilePath(2147483647)),
|
|
164
|
+
(err) => err.code === 'ENOENT',
|
|
165
|
+
'owner 死了的文件应当被自愈删除'
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('没有活跃 job:写空数组 = 直接删文件,不留空壳', async () => {
|
|
170
|
+
await writeLiveJobsFile(4242, [toLiveRecord(runningJob('j-live'))]);
|
|
171
|
+
await writeLiveJobsFile(4242, []);
|
|
172
|
+
await assert.rejects(fs.stat(liveJobsFilePath(4242)), (err) => err.code === 'ENOENT');
|
|
173
|
+
const { signature, jobs: found } = await scanLiveJobsDir({ isProcessAlive: () => true });
|
|
174
|
+
assert.equal(found.length, 0);
|
|
175
|
+
assert.equal(signature, '');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('removeLiveJobsFile 幂等:文件不在也不抛', async () => {
|
|
179
|
+
await removeLiveJobsFile(999999);
|
|
180
|
+
await removeLiveJobsFile(999999);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('目录里的垃圾文件(写中间的 .tmp / 非 pid 名)不参与扫描', async () => {
|
|
184
|
+
await writeLiveJobsFile(4242, [toLiveRecord(runningJob('j-live'))]);
|
|
185
|
+
await fs.writeFile(path.join(LIVE_JOBS_DIR, '4242.json.tmp'), '{ 半截', 'utf-8');
|
|
186
|
+
await fs.writeFile(path.join(LIVE_JOBS_DIR, 'notes.txt'), 'x', 'utf-8');
|
|
187
|
+
await fs.writeFile(path.join(LIVE_JOBS_DIR, '.migrated'), 'x', 'utf-8');
|
|
188
|
+
const { jobs: found } = await scanLiveJobsDir({ isProcessAlive: () => true });
|
|
189
|
+
assert.deepEqual(found.map(j => j.id), ['j-live']);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('损坏的 JSON 文件只跳过,不把整次扫描搞崩', async () => {
|
|
193
|
+
await writeLiveJobsFile(4242, [toLiveRecord(runningJob('j-ok'))]);
|
|
194
|
+
await fs.mkdir(LIVE_JOBS_DIR, { recursive: true });
|
|
195
|
+
await fs.writeFile(path.join(LIVE_JOBS_DIR, '7777.json'), 'not json at all', 'utf-8');
|
|
196
|
+
const { jobs: found } = await scanLiveJobsDir({ isProcessAlive: () => true });
|
|
197
|
+
assert.deepEqual(found.map(j => j.id), ['j-ok']);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('ownerPid 与文件名对不上(被谁改过)的记录不采信', async () => {
|
|
201
|
+
await fs.mkdir(LIVE_JOBS_DIR, { recursive: true });
|
|
202
|
+
await fs.writeFile(
|
|
203
|
+
path.join(LIVE_JOBS_DIR, '4242.json'),
|
|
204
|
+
JSON.stringify({ version: 1, ownerPid: 1, updatedAt: Date.now(), jobs: [runningJob('j-x')] }),
|
|
205
|
+
'utf-8'
|
|
206
|
+
);
|
|
207
|
+
const { jobs: found } = await scanLiveJobsDir({ isProcessAlive: () => true });
|
|
208
|
+
assert.equal(found.length, 0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('目录不存在时扫描返回空,不抛', async () => {
|
|
212
|
+
const { signature, jobs: found } = await scanLiveJobsDir();
|
|
213
|
+
assert.equal(signature, '');
|
|
214
|
+
assert.deepEqual(found, []);
|
|
215
|
+
});
|