zen-gitsync 2.17.19 → 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/config.js +3 -0
- 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-mUmFBb7H.css +1 -0
- 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-Ut942ckG.js → _plugin-vue_export-helper-N8Vc5eEs.js} +5 -5
- package/src/ui/public/assets/{agentConversations-C2864Cya.js → agentConversations-Da7zLPfP.js} +2 -2
- package/src/ui/public/assets/configStore-BFb0I8eP.js +1 -0
- package/src/ui/public/assets/element-plus-Bx3j6vzG.js +28 -0
- package/src/ui/public/assets/{flow-mindmap-DnzM9SlK.css → flow-mindmap-BMpmHdqh.css} +1 -1
- package/src/ui/public/assets/flow-mindmap-OPE_D4da.js +257 -0
- package/src/ui/public/assets/{gitStore-DyaXgTSR.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-SqNQvuHv.css +1 -0
- package/src/ui/public/assets/socket-io-TBtw0msS.js +1 -0
- package/src/ui/public/assets/{toolsStore-DrPVMxvn.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-B5GNSK12.js → vendor-CyWsJd6S.js} +252 -109
- package/src/ui/public/assets/vue-flow-fVVGZspa.js +39 -0
- package/src/ui/public/index.html +25 -25
- package/src/ui/server/index.js +8 -0
- package/src/ui/server/routes/fs.js +98 -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-DUf0TfaU.css +0 -1
- package/src/ui/public/assets/AgentEngineSelector-j7LYtcCD.js +0 -2
- package/src/ui/public/assets/AgentView-B8zgTjEM.js +0 -10
- package/src/ui/public/assets/AgentView-phgvR10P.css +0 -1
- package/src/ui/public/assets/AppVersionBadge-eo7ROtED.js +0 -2
- package/src/ui/public/assets/BranchSelector-BwrAEUNg.js +0 -1
- package/src/ui/public/assets/CommitForm-4kI2csgs.css +0 -1
- package/src/ui/public/assets/CommitForm-BIx30QyK.js +0 -6
- package/src/ui/public/assets/CommonDialog-CXmqa6q4.js +0 -1
- package/src/ui/public/assets/CommonDialog-w6EmBEXv.css +0 -1
- package/src/ui/public/assets/EditorView-Bo176FOY.js +0 -31
- package/src/ui/public/assets/EditorView-D10uW5AV.css +0 -1
- package/src/ui/public/assets/FileDiffViewer-DXnoEMNi.js +0 -15
- package/src/ui/public/assets/FileDiffViewer-n5ea9qMe.css +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-D_pSWT0R.js +0 -1
- package/src/ui/public/assets/FlowExecutionViewer-jNHI5zdm.css +0 -1
- package/src/ui/public/assets/FlowOrchestrationWorkspace-CcG6dBBg.js +0 -21
- package/src/ui/public/assets/FlowOrchestrationWorkspace-DwuSSxJ1.css +0 -1
- package/src/ui/public/assets/IconButton-RlXqKOeu.css +0 -1
- package/src/ui/public/assets/IconButton-jqB5gLxI.js +0 -1
- package/src/ui/public/assets/LogList-CtrKeYUA.js +0 -7
- package/src/ui/public/assets/LogList-XspUhx-C.css +0 -1
- package/src/ui/public/assets/MindmapView-DUWUbAgc.js +0 -2
- package/src/ui/public/assets/MindmapView-Diq2Cu4O.css +0 -1
- package/src/ui/public/assets/MonitorView-CLW2-HBy.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-CErZT4zR.js +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-CO7G6tRt.css +0 -1
- package/src/ui/public/assets/RecentDirectoriesChat-cVsO1elA.js +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-D4WphMtO.css +0 -1
- package/src/ui/public/assets/RemoteManagerDialog-sksn2MmQ.js +0 -4
- package/src/ui/public/assets/RemoteRepoCard-Cz_aoLZb.js +0 -7
- package/src/ui/public/assets/RemoteRepoCard-o01IrbVz.css +0 -1
- package/src/ui/public/assets/SourceMapView-CfDydtsu.css +0 -1
- package/src/ui/public/assets/SourceMapView-D5qkdXhn.js +0 -3
- package/src/ui/public/assets/SvgIcon-D54dZy2f.js +0 -1
- package/src/ui/public/assets/TemplateManager-DO7EPfj8.css +0 -1
- package/src/ui/public/assets/TemplateManager-DRHRpB64.js +0 -1
- package/src/ui/public/assets/UserInputNode-C4NV2vaL.js +0 -1
- package/src/ui/public/assets/UserInputNode-DDNSarag.css +0 -1
- package/src/ui/public/assets/WorkbenchView-B3VcBlk7.js +0 -70
- package/src/ui/public/assets/WorkbenchView-ZDvzGafG.css +0 -1
- package/src/ui/public/assets/configStore-DG0VBR3h.js +0 -1
- package/src/ui/public/assets/element-plus-AaVXaSqv.js +0 -48
- package/src/ui/public/assets/flow-mindmap-j-zhcNPe.js +0 -139
- package/src/ui/public/assets/index-B5MKbT-v.css +0 -1
- package/src/ui/public/assets/index-Dq6OyCrE.js +0 -127
- 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-CxLaNg4X.css +0 -1
- package/src/ui/public/assets/previewSandbox-D2ioUBVm.js +0 -0
- package/src/ui/public/assets/socket-io-BFqQXqAp.js +0 -1
- package/src/ui/public/assets/vue-flow-DwYB6Myr.js +0 -39
|
@@ -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
|
+
});
|
|
@@ -14,12 +14,17 @@
|
|
|
14
14
|
//
|
|
15
15
|
// 主 Agent 编排台的状态存储 + 活动流拼装。
|
|
16
16
|
//
|
|
17
|
-
//
|
|
17
|
+
// 四件事:
|
|
18
18
|
// 1. 调度开关(active)+ 人类干预指令存档,持久化到 ~/.zen-gitsync/orchestrator.json。
|
|
19
19
|
// 「暂停调度」是服务端强制的:暂停期间自动派发会被拒绝(手动点执行不受影响)。
|
|
20
20
|
// 2. 派发默认提示词:一条全局的 + 每个项目一条的,同样存在 orchestrator.json。
|
|
21
21
|
// 它们是「派发时自动附加的约束」,不是任务内容 —— 解析规则见 resolveDispatchPrompt。
|
|
22
|
-
// 3.
|
|
22
|
+
// 3. 进度报告:自动报告的间隔配置存在 orchestrator.json(一个数字,跟着轮询下发),
|
|
23
|
+
// 历史报告存在 orchestrator-reports.json(几 KB 一份,只在面板打开时按需取)。
|
|
24
|
+
// 报告正文由 progressReport.js 生成,这里只负责"存哪、存几份、什么时候允许再生成"。
|
|
25
|
+
// 4. buildActivityFeed —— 把 job 的起止事实与人类指令合成一条时间倒序的活动流。
|
|
26
|
+
// (控制台右栏的「活动日志」已换成进度报告,但这条流还在供顶栏「今日完成」计数,
|
|
27
|
+
// 以及编排台接口的数据完整性 —— 别看到没人渲染就把它删了。)
|
|
23
28
|
//
|
|
24
29
|
// 活动流为什么不在服务端拼好文案:
|
|
25
30
|
// UI 文案统一走前端 i18n(lang/{zh,en}),服务端只回**结构化事实**
|
|
@@ -31,8 +36,12 @@
|
|
|
31
36
|
|
|
32
37
|
import {
|
|
33
38
|
ORCHESTRATOR_FILE,
|
|
39
|
+
ORCHESTRATOR_REPORTS_FILE,
|
|
34
40
|
MAX_ORCHESTRATOR_INSTRUCTIONS,
|
|
35
41
|
MAX_DEFAULT_PROMPT_CHARS,
|
|
42
|
+
MAX_PROGRESS_REPORTS,
|
|
43
|
+
PROGRESS_REPORT_INTERVALS_MS,
|
|
44
|
+
DEFAULT_PROGRESS_REPORT_INTERVAL_MS,
|
|
36
45
|
readJson,
|
|
37
46
|
writeJson,
|
|
38
47
|
nowIso,
|
|
@@ -56,9 +65,20 @@ function defaultState() {
|
|
|
56
65
|
instructions: [],
|
|
57
66
|
defaultPrompt: '',
|
|
58
67
|
projectPrompts: {},
|
|
68
|
+
reportIntervalMs: DEFAULT_PROGRESS_REPORT_INTERVAL_MS,
|
|
69
|
+
lastReportAt: null,
|
|
59
70
|
};
|
|
60
71
|
}
|
|
61
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 报告间隔归一。**只认白名单里的值**,其余(缺字段 / 脏值 / 手改文件写进去的 7 分钟)
|
|
75
|
+
* 一律回落到默认 10 分钟。老版本没有这个字段 —— 那正是"升级后按新默认开始报告"的语义。
|
|
76
|
+
*/
|
|
77
|
+
export function normalizeReportIntervalMs(value) {
|
|
78
|
+
const n = Number(value);
|
|
79
|
+
return PROGRESS_REPORT_INTERVALS_MS.includes(n) ? n : DEFAULT_PROGRESS_REPORT_INTERVAL_MS;
|
|
80
|
+
}
|
|
81
|
+
|
|
62
82
|
/** 单条指令归一:老数据/半损坏数据都要能渲染,不能让整份状态读不出来 */
|
|
63
83
|
function normalizeInstruction(raw) {
|
|
64
84
|
const it = raw && typeof raw === 'object' ? raw : {};
|
|
@@ -122,6 +142,8 @@ export function normalizeOrchestrator(raw) {
|
|
|
122
142
|
? state.defaultPrompt.slice(0, MAX_DEFAULT_PROMPT_CHARS)
|
|
123
143
|
: '',
|
|
124
144
|
projectPrompts: normalizeProjectPrompts(state.projectPrompts),
|
|
145
|
+
reportIntervalMs: normalizeReportIntervalMs(state.reportIntervalMs),
|
|
146
|
+
lastReportAt: typeof state.lastReportAt === 'string' ? state.lastReportAt : null,
|
|
125
147
|
};
|
|
126
148
|
}
|
|
127
149
|
|
|
@@ -258,6 +280,95 @@ export async function clearInstructions() {
|
|
|
258
280
|
});
|
|
259
281
|
}
|
|
260
282
|
|
|
283
|
+
// ── 进度报告的配置与历史 ────────────────────────────────────────────────
|
|
284
|
+
// 「报告时间我自己设置」的那个设置:0 = 关掉自动报告(手动「立即报告」始终可用,
|
|
285
|
+
// 它不受这个开关影响 —— 关掉的是"自己隔一会儿跑一次",不是"不许报告")。
|
|
286
|
+
|
|
287
|
+
/** 写自动报告间隔。返回写后的值(已归一) */
|
|
288
|
+
export async function setReportIntervalMs(value) {
|
|
289
|
+
return serialize(async () => {
|
|
290
|
+
const state = await readOrchestrator();
|
|
291
|
+
state.reportIntervalMs = normalizeReportIntervalMs(value);
|
|
292
|
+
state.updatedAt = nowIso();
|
|
293
|
+
await persist(state);
|
|
294
|
+
return state.reportIntervalMs;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* 抢占一次自动报告的"名额":距上次报告不足 intervalMs 就返回 false。
|
|
300
|
+
*
|
|
301
|
+
* 为什么占坑要写盘而不是只放内存里一个时间戳:报告历史是**多实例共享**的
|
|
302
|
+
* (同一个 ~/.zen-gitsync 可能同时开着两个 g ui),各自跑一个定时器的话,
|
|
303
|
+
* 内存里各记各的,两边会在同一分钟各生成一份内容几乎一样的报告。
|
|
304
|
+
* 时间戳落在共享文件上,后到的那个实例读到的就是"刚报过"。
|
|
305
|
+
*
|
|
306
|
+
* 抢占发生在**生成之前**(生成要等一次模型往返,几秒到几十秒):
|
|
307
|
+
* 等生成完再写,两个实例的窗口就大得多。代价是生成失败也要等满一个间隔才重试 ——
|
|
308
|
+
* 失败会记进报告历史(errorCode),比"同一分钟内连打两次模型"划算。
|
|
309
|
+
*/
|
|
310
|
+
export async function claimReportSlot(intervalMs, now = Date.now()) {
|
|
311
|
+
return serialize(async () => {
|
|
312
|
+
const state = await readOrchestrator();
|
|
313
|
+
const last = Date.parse(state.lastReportAt || '');
|
|
314
|
+
if (Number.isFinite(last) && now - last < intervalMs) return false;
|
|
315
|
+
state.lastReportAt = new Date(now).toISOString();
|
|
316
|
+
await persist(state);
|
|
317
|
+
return true;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** 单份报告归一:字段缺失 / 脏数据都必须能渲染,不能让面板整个读不出来 */
|
|
322
|
+
function normalizeReport(raw) {
|
|
323
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
324
|
+
const tasks = Array.isArray(r.tasks) ? r.tasks : [];
|
|
325
|
+
return {
|
|
326
|
+
id: typeof r.id === 'string' && r.id ? r.id : genId(),
|
|
327
|
+
at: typeof r.at === 'string' ? r.at : null,
|
|
328
|
+
trigger: r.trigger === 'auto' ? 'auto' : 'manual',
|
|
329
|
+
text: typeof r.text === 'string' ? r.text : '',
|
|
330
|
+
errorCode: typeof r.errorCode === 'string' ? r.errorCode : '',
|
|
331
|
+
errorDetail: typeof r.errorDetail === 'string' ? r.errorDetail : '',
|
|
332
|
+
tasks: tasks
|
|
333
|
+
.filter(t => t && typeof t === 'object')
|
|
334
|
+
.map(t => ({
|
|
335
|
+
taskId: typeof t.taskId === 'string' ? t.taskId : null,
|
|
336
|
+
taskTitle: typeof t.taskTitle === 'string' ? t.taskTitle : '',
|
|
337
|
+
projectName: typeof t.projectName === 'string' ? t.projectName : '',
|
|
338
|
+
startedAt: typeof t.startedAt === 'string' ? t.startedAt : null,
|
|
339
|
+
// 跑多久是**生成那一刻**的事实,跟着报告存下来:
|
|
340
|
+
// 前端拿现在的时钟去减 startedAt,"已运行 12 分钟"会随报告变旧一路涨,
|
|
341
|
+
// 历史报告就再也说不清"当时是什么情况"了。
|
|
342
|
+
elapsedMs: Number.isFinite(Number(t.elapsedMs)) ? Math.max(0, Math.floor(Number(t.elapsedMs))) : 0,
|
|
343
|
+
agent: typeof t.agent === 'string' ? t.agent : '',
|
|
344
|
+
toolCallCount: Number.isFinite(Number(t.toolCallCount)) ? Math.max(0, Math.floor(Number(t.toolCallCount))) : 0,
|
|
345
|
+
lastTool: typeof t.lastTool === 'string' ? t.lastTool : '',
|
|
346
|
+
lastLine: typeof t.lastLine === 'string' ? t.lastLine : '',
|
|
347
|
+
})),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 读报告历史(新的在前)。文件缺失 / 损坏一律当"还没有报告",不抛错 */
|
|
352
|
+
export async function readReports() {
|
|
353
|
+
const data = await readJson(ORCHESTRATOR_REPORTS_FILE, null);
|
|
354
|
+
const list = data && Array.isArray(data.reports) ? data.reports : [];
|
|
355
|
+
return list
|
|
356
|
+
.map(normalizeReport)
|
|
357
|
+
.sort((a, b) => String(b.at || '').localeCompare(String(a.at || '')))
|
|
358
|
+
.slice(0, MAX_PROGRESS_REPORTS);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** 追加一份报告(新的在前),超出上限的旧报告直接丢掉 */
|
|
362
|
+
export async function appendReport(entry) {
|
|
363
|
+
return serialize(async () => {
|
|
364
|
+
const report = normalizeReport({ id: genId(), at: nowIso(), ...entry });
|
|
365
|
+
const existing = await readReports();
|
|
366
|
+
const reports = [report, ...existing].slice(0, MAX_PROGRESS_REPORTS);
|
|
367
|
+
await writeJson(ORCHESTRATOR_REPORTS_FILE, { version: 1, reports });
|
|
368
|
+
return report;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
261
372
|
/**
|
|
262
373
|
* 正在执行的执行体清单(看板左栏「执行监控」用)。
|
|
263
374
|
*
|