zen-gitsync 2.13.22 → 2.13.24
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/package.json +1 -1
- package/src/gitCommit.js +2 -2
- package/src/ui/public/assets/{AppVersionBadge-CS59-kD5.js → AppVersionBadge-CaVphp8E.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-DulCErgN.js → BranchSelector-B91Ylbas.js} +1 -1
- package/src/ui/public/assets/{CommandConsole-H1j4BsHx.js → CommandConsole-bK4iwZvI.js} +2 -2
- package/src/ui/public/assets/{CommitForm-DIdLEAZ_.js → CommitForm-zOCxmZ84.js} +1 -1
- package/src/ui/public/assets/{CustomCommandManager-Cm5yU0TM.js → CustomCommandManager-C1lRrp5z.js} +1 -1
- package/src/ui/public/assets/{EditorView-CTvDExIi.js → EditorView-DYo2oYtk.js} +0 -0
- package/src/ui/public/assets/{FileDiffViewer-C7Cosx8a.js → FileDiffViewer-Ce17WOZ-.js} +1 -1
- package/src/ui/public/assets/{FlowExecutionViewer-BeGTXBVV.js → FlowExecutionViewer-CRK6lIul.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-CuqAZCaM.js → FlowOrchestrationWorkspace-BDw7ThWp.js} +1 -1
- package/src/ui/public/assets/{GitStatus-DBDjyLJD.js → GitStatus-DN4_m6sO.js} +1 -1
- package/src/ui/public/assets/{ImagePreview-C8eIs5yL.js → ImagePreview-D8klNa6F.js} +1 -1
- package/src/ui/public/assets/{LogList-YCJ_gVWr.js → LogList-BshRTCrE.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-gWSO3HCJ.js → ProjectStartupButton-C-dXxxMO.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-9iWOAx2g.js → RemoteRepoCard-DDZcP5-V.js} +1 -1
- package/src/ui/public/assets/{ResetToRemoteButton-BckRJ_lM.js → ResetToRemoteButton-TXJzvBxE.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-DWBowIx1.js → SourceMapView-BUkB6h41.js} +1 -1
- package/src/ui/public/assets/{TemplateManager-DiKpGMhi.js → TemplateManager-DFVzDiu0.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-CU11vBBn.js → UserInputNode-CXyV1xG4.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-CEYhsa4O.js +12 -0
- package/src/ui/public/assets/WorkbenchView-DvML7yIv.css +1 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-vLPduyLG.js → _plugin-vue_export-helper-BfbhkIv4.js} +2 -2
- package/src/ui/public/assets/{index-BxxqloTb.js → index-BGmxuKoN.js} +3 -3
- package/src/ui/public/index.html +2 -2
- package/src/ui/server/index.js +3 -3
- package/src/ui/server/routes/branchStatus.js +4 -4
- package/src/ui/server/routes/config.js +8 -8
- package/src/ui/server/routes/exec.js +7 -1
- package/src/ui/server/routes/fs.js +2 -2
- package/src/ui/server/routes/git/diff.js +17 -16
- package/src/ui/server/routes/git/diffUtils.js +21 -2
- package/src/ui/server/routes/git/stash.js +38 -42
- package/src/ui/server/routes/git/tags.js +10 -10
- package/src/ui/server/routes/git.js +15 -26
- package/src/ui/server/routes/gitOps.js +91 -125
- package/src/ui/server/routes/status.js +3 -3
- package/src/ui/server/routes/workbench.js +160 -23
- package/src/utils/index.js +32 -29
- package/src/ui/public/assets/WorkbenchView-BsVMwobR.js +0 -12
- package/src/ui/public/assets/WorkbenchView-DslIMLgz.css +0 -1
|
@@ -388,8 +388,70 @@ async function listDirTree(projectPath, maxDepth = 2, maxEntries = 400) {
|
|
|
388
388
|
return lines.join('\n');
|
|
389
389
|
}
|
|
390
390
|
|
|
391
|
+
// 剥离 LLM 输出的 思考块、```json``` 代码块围栏,以及首尾说明文字。
|
|
392
|
+
// 原贪婪正则 /({[\s\S]*})/ 会把 思考块(内含未配对花括号或字符串)一起吞进 JSON.parse,
|
|
393
|
+
// 在 DeepSeek 等会输出 推理链的模型上偶发 "Unterminated string in JSON"。
|
|
394
|
+
function stripThinkingBlocks(content) {
|
|
395
|
+
let s = String(content || '');
|
|
396
|
+
// 1) 显式 思考块(成对标签)
|
|
397
|
+
s = s.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, '');
|
|
398
|
+
// 2) 某些 provider 流式结尾会留下裸 <think>...</think> 之外的残段(无结束标签时被截断)
|
|
399
|
+
s = s.replace(/<think(?:ing)?>[\s\S]*$/gi, '');
|
|
400
|
+
// 3) ```json ... ``` 代码块围栏(保留内部 JSON)
|
|
401
|
+
s = s.replace(/```json\s*/gi, '').replace(/```/g, '');
|
|
402
|
+
return s;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 在剥离 思考块 的文本里定位第一个「平衡花括号对象」的起点/终点。
|
|
406
|
+
// 跳过字符串内的 {/}(含转义)以及 Jinja {{ }} 模板占位符;
|
|
407
|
+
// 同时只在「JSON 特征显著」的 { 处起算 —— 即该 { 之后不远处出现 " 字符串。
|
|
408
|
+
// 这能避免把 LLM 思考文本里的裸 { ... }(如 "see { views, stores }")误当 JSON 起点。
|
|
409
|
+
function extractFirstJsonObject(content) {
|
|
410
|
+
const s = String(content || '');
|
|
411
|
+
let depth = 0;
|
|
412
|
+
let inStr = false;
|
|
413
|
+
let escape = false;
|
|
414
|
+
let start = -1;
|
|
415
|
+
for (let i = 0; i < s.length; i++) {
|
|
416
|
+
const ch = s[i];
|
|
417
|
+
if (inStr) {
|
|
418
|
+
if (escape) { escape = false; continue; }
|
|
419
|
+
if (ch === '\\') { escape = true; continue; }
|
|
420
|
+
if (ch === '"') { inStr = false; }
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (ch === '"') { inStr = true; continue; }
|
|
424
|
+
// 跳过 Jinja {{ }} 占位符(提示词模板常用,勿当 JSON)
|
|
425
|
+
if (ch === '{' && s[i + 1] === '{') { i++; continue; }
|
|
426
|
+
if (ch === '}' && s[i - 1] === '}') { continue; }
|
|
427
|
+
if (ch === '{') {
|
|
428
|
+
if (depth === 0) {
|
|
429
|
+
// 启发式判断「这看起来像 JSON 起点」:
|
|
430
|
+
// 1. { 之前紧邻「真正的语义字符」(字母/数字/中文/下划线) → 不是 JSON 起点(散文里的花括号)
|
|
431
|
+
// 但接受 . , ; : 等句末标点(LLM 经常以「总结:{...}」直接开头 JSON)
|
|
432
|
+
// 2. { 之后跳过空白第一个字符必须是 " 或 }(空对象)
|
|
433
|
+
const before = i > 0 ? s[i - 1] : '';
|
|
434
|
+
if (/[A-Za-z0-9_一-龥]/.test(before)) continue;
|
|
435
|
+
let j = i + 1;
|
|
436
|
+
while (j < s.length && /\s/.test(s[j])) j++;
|
|
437
|
+
const firstNonWs = s[j];
|
|
438
|
+
if (firstNonWs !== '"' && firstNonWs !== '}') continue;
|
|
439
|
+
start = i;
|
|
440
|
+
}
|
|
441
|
+
depth++;
|
|
442
|
+
} else if (ch === '}') {
|
|
443
|
+
if (depth > 0) depth--;
|
|
444
|
+
if (depth === 0 && start !== -1) {
|
|
445
|
+
return s.slice(start, i + 1);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// 兜底:找不到平衡对象时返回原文(让上层 JSON.parse 报清晰错误)
|
|
450
|
+
return s;
|
|
451
|
+
}
|
|
452
|
+
|
|
391
453
|
async function callLlmJson(model, prompt, opts = {}) {
|
|
392
|
-
const {
|
|
454
|
+
const { timeoutMs = 60000, images = [] } = opts;
|
|
393
455
|
const { default: fetch } = await import('node-fetch').catch(() => ({ default: globalThis.fetch }));
|
|
394
456
|
const url = `${String(model.baseURL || '').replace(/\/$/, '')}/chat/completions`;
|
|
395
457
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -410,7 +472,6 @@ async function callLlmJson(model, prompt, opts = {}) {
|
|
|
410
472
|
const body = JSON.stringify({
|
|
411
473
|
model: model.model,
|
|
412
474
|
messages: [{ role: 'user', content: userContent }],
|
|
413
|
-
max_tokens: maxTokens,
|
|
414
475
|
temperature: 0.4,
|
|
415
476
|
response_format: { type: 'json_object' },
|
|
416
477
|
stream: false,
|
|
@@ -422,18 +483,37 @@ async function callLlmJson(model, prompt, opts = {}) {
|
|
|
422
483
|
const resp = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
|
423
484
|
const data = await resp.json().catch(() => ({}));
|
|
424
485
|
if (!resp.ok) throw new Error(data?.error?.message || `HTTP ${resp.status}`);
|
|
425
|
-
const content = data?.choices?.[0]?.message?.content || '
|
|
486
|
+
const content = data?.choices?.[0]?.message?.content || '';
|
|
487
|
+
if (!content.trim()) {
|
|
488
|
+
throw new Error('LLM 返回内容为空');
|
|
489
|
+
}
|
|
426
490
|
try {
|
|
427
|
-
const
|
|
428
|
-
return JSON.parse(
|
|
429
|
-
} catch {
|
|
430
|
-
|
|
491
|
+
const cleaned = stripThinkingBlocks(content);
|
|
492
|
+
return JSON.parse(extractFirstJsonObject(cleaned));
|
|
493
|
+
} catch (err) {
|
|
494
|
+
const snippet = content.length > 500 ? content.slice(0, 500) + '…' : content;
|
|
495
|
+
throw new Error(`LLM 返回非 JSON 或被截断:${err.message};原始内容片段:${snippet}`);
|
|
431
496
|
}
|
|
432
497
|
} finally {
|
|
433
498
|
clearTimeout(timer);
|
|
434
499
|
}
|
|
435
500
|
}
|
|
436
501
|
|
|
502
|
+
async function callLlmJsonWithRetry(model, prompt, opts = {}, retries = 1) {
|
|
503
|
+
let lastErr;
|
|
504
|
+
for (let i = 0; i <= retries; i++) {
|
|
505
|
+
try {
|
|
506
|
+
return await callLlmJson(model, prompt, opts);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
lastErr = err;
|
|
509
|
+
if (i < retries) {
|
|
510
|
+
await new Promise(r => setTimeout(r, 800 * (i + 1)));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
throw lastErr;
|
|
515
|
+
}
|
|
516
|
+
|
|
437
517
|
/**
|
|
438
518
|
* 流式调用 OpenAI 兼容 LLM。每收到一个 chunk 调 onDelta 回调。
|
|
439
519
|
* onDelta 接收 { thinking?: string, content?: string },二选一。
|
|
@@ -1118,13 +1198,21 @@ async function runTaskQueue(task, repoPath, branch, opts) {
|
|
|
1118
1198
|
const priorOutputs = fromIndex > 0
|
|
1119
1199
|
? await collectPriorOutputsUpTo(task, fromIndex)
|
|
1120
1200
|
: []
|
|
1201
|
+
// 连续模式(默认):任意 sub 终态非 done(cancelled / error)→ 整批停,后续 sub 保持 todo。
|
|
1202
|
+
// AI 拆出来的 sub 一般前后强依赖(前一步产出是后一步输入),出错就停下来让用户决策。
|
|
1203
|
+
// 关闭后回退旧行为:单个 sub 失败不影响后续 sub 继续跑。
|
|
1204
|
+
const sequential = task.sequential !== false
|
|
1121
1205
|
for (let i = fromIndex; i < task.subtasks.length; i++) {
|
|
1122
1206
|
const sub = task.subtasks[i]
|
|
1123
1207
|
if (sub.status === 'done') continue;
|
|
1124
|
-
await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
|
|
1208
|
+
const outcome = await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
|
|
1125
1209
|
// 逐 sub 落盘:之前只在队列跑完才 persistTaskAfterRun,中途崩溃会丢已完成
|
|
1126
1210
|
// sub 的状态。runSingleSubtask 已经把 sub.status 改完,这里补一次落盘。
|
|
1127
1211
|
await persistTaskAfterRun(task)
|
|
1212
|
+
if (sequential && outcome !== 'done') {
|
|
1213
|
+
// cancelled / error → 后续 sub 全部保持 todo,不再继续
|
|
1214
|
+
break
|
|
1215
|
+
}
|
|
1128
1216
|
}
|
|
1129
1217
|
}
|
|
1130
1218
|
|
|
@@ -1325,7 +1413,12 @@ ${prompt}`
|
|
|
1325
1413
|
job.exitCode = 130; // 128 + SIGINT(2),约定俗成的"用户取消"退出码
|
|
1326
1414
|
job.status = 'cancelled';
|
|
1327
1415
|
job.error = '用户已停止执行';
|
|
1328
|
-
// sub
|
|
1416
|
+
// 同步把闭包里的 sub 也置 cancelled(cancel 接口的 syncSubToCancelled 改了磁盘 task
|
|
1417
|
+
// 引用,但 runSingleSubtask 形参里的 sub 是另一份内存引用)。否则 finally publish sub:update
|
|
1418
|
+
// 会用 status='running' 覆盖掉前端已渲染的 cancelled 状态,导致 UI 反复跳回 running。
|
|
1419
|
+
// 同时 sequential=true 时 runTaskQueue 会看到 outcome='cancelled',break 不再跑后续 sub。
|
|
1420
|
+
sub.status = 'cancelled';
|
|
1421
|
+
if (!sub.error) sub.error = '用户已停止执行';
|
|
1329
1422
|
} else {
|
|
1330
1423
|
job.exitCode = 0;
|
|
1331
1424
|
job.status = 'done';
|
|
@@ -1359,6 +1452,36 @@ ${prompt}`
|
|
|
1359
1452
|
console.warn('[workbench] flushJobsSaveNow failed (job id=' + job.id + ', status=' + job.status + '):', err && err.message || err)
|
|
1360
1453
|
}
|
|
1361
1454
|
}
|
|
1455
|
+
// 把 sub 的终态返回给 runTaskQueue,用于「连续模式」判断要不要 break 整批队列
|
|
1456
|
+
return job.status // 'done' | 'cancelled' | 'error'
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* 把被取消的 sub 同步置 'cancelled' 并落盘。
|
|
1461
|
+
* cancelJob 路径专用:前端 taskIsRunning/sub.is-running 都看 sub.status,不改就会出现
|
|
1462
|
+
* "主任务黄点 + sub running 动效 + 右侧执行完成"三处不一致。
|
|
1463
|
+
*
|
|
1464
|
+
* 简单任务的虚拟 subId 不在 tasks.json 里(task.subtasks 是 complex 才有),所以这里
|
|
1465
|
+
* 找不到 sub 时静默返回;简单任务的 running 状态由 job 数组单独维护(见 taskIsRunning)。
|
|
1466
|
+
*
|
|
1467
|
+
* @returns {{ taskId: string, sub: object } | null} 找到并更新时返回新 sub,否则 null
|
|
1468
|
+
*/
|
|
1469
|
+
async function syncSubToCancelled(job) {
|
|
1470
|
+
if (!job || !job.taskId || !job.subId) return null
|
|
1471
|
+
const data = await readJson(TASKS_FILE, { tasks: [] })
|
|
1472
|
+
const task = (data.tasks || []).find(x => x.id === job.taskId)
|
|
1473
|
+
if (!task || !Array.isArray(task.subtasks)) return null
|
|
1474
|
+
const sub = task.subtasks.find(s => s && s.id === job.subId)
|
|
1475
|
+
if (!sub) return null
|
|
1476
|
+
if (sub.status === 'cancelled') return { taskId: task.id, sub } // 已置过,幂等返回
|
|
1477
|
+
sub.status = 'cancelled'
|
|
1478
|
+
sub.error = '用户已停止执行'
|
|
1479
|
+
sub.errorAt = nowIso()
|
|
1480
|
+
task.updatedAt = nowIso()
|
|
1481
|
+
await writeJson(TASKS_FILE, data)
|
|
1482
|
+
publish('sub:update', { taskId: task.id, sub })
|
|
1483
|
+
publish('task:update', task)
|
|
1484
|
+
return { taskId: task.id, sub }
|
|
1362
1485
|
}
|
|
1363
1486
|
|
|
1364
1487
|
/** 把 task.subtasks 写回 tasks.json,并广播 task:update。runTaskQueue 和"单 sub 执行"共用。 */
|
|
@@ -1488,7 +1611,7 @@ export function registerWorkbenchRoutes({ app, getCurrentProjectPath, getProject
|
|
|
1488
1611
|
}
|
|
1489
1612
|
|
|
1490
1613
|
const projectName = path.basename(projectPath);
|
|
1491
|
-
const LLM_OPTS = {
|
|
1614
|
+
const LLM_OPTS = { timeoutMs: 1200000 };
|
|
1492
1615
|
|
|
1493
1616
|
// ── 第一阶段:基于可编辑指令 + 根目录概览,生成「可复用的提示词模板」 ──
|
|
1494
1617
|
const overviewBlock = subProjects.map(sp =>
|
|
@@ -1522,7 +1645,7 @@ ${subProjects.map(sp => {
|
|
|
1522
1645
|
"template": "可复用的提示词模板,长度不限,请充分覆盖 {{task.title}} / {{task.desc}} / {{sub.title}} / {{sub.desc}} / {{repo.path}} / {{branch}} 这 6 个变量的用法与上下文"
|
|
1523
1646
|
}`;
|
|
1524
1647
|
|
|
1525
|
-
const first = await
|
|
1648
|
+
const first = await callLlmJsonWithRetry(model, firstPrompt, LLM_OPTS);
|
|
1526
1649
|
const templateName = String(first.name || '').trim() || `${projectName}架构说明`;
|
|
1527
1650
|
const template = String(first.template || '').trim();
|
|
1528
1651
|
|
|
@@ -1553,11 +1676,15 @@ ${sp.readme || '(无)'}
|
|
|
1553
1676
|
{
|
|
1554
1677
|
"summary": "该子项目的架构说明,长度不限,模型自行决定篇幅与详尽程度,能写多详细就多详细"
|
|
1555
1678
|
}`;
|
|
1556
|
-
const r = await
|
|
1679
|
+
const r = await callLlmJsonWithRetry(model, subPrompt, LLM_OPTS);
|
|
1557
1680
|
return { name: sp.name, root: sp.root, summary: String(r.summary || '').trim() };
|
|
1558
1681
|
}
|
|
1559
1682
|
|
|
1560
|
-
|
|
1683
|
+
// 串行执行,避免并发触发 provider 限流(429)导致整批失败
|
|
1684
|
+
const subSummaries = [];
|
|
1685
|
+
for (const sp of subProjects) {
|
|
1686
|
+
subSummaries.push(await summarizeOneSub(sp));
|
|
1687
|
+
}
|
|
1561
1688
|
|
|
1562
1689
|
// ── 第三阶段:仅多子项目时合并(单子项目直接拿它的 summary) ──
|
|
1563
1690
|
let finalSummary = '';
|
|
@@ -1578,7 +1705,7 @@ ${sp.readme || '(无)'}
|
|
|
1578
1705
|
## 子项目说明
|
|
1579
1706
|
${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summary || '(空)'}`).join('\n')}`;
|
|
1580
1707
|
|
|
1581
|
-
const merged = await
|
|
1708
|
+
const merged = await callLlmJsonWithRetry(model, mergePrompt, LLM_OPTS);
|
|
1582
1709
|
finalSummary = String(merged.summary || '').trim()
|
|
1583
1710
|
|| subSummaries.map(s => `### ${s.name}\n${s.summary}`).join('\n\n');
|
|
1584
1711
|
finalName = String(merged.name || '').trim() || templateName;
|
|
@@ -1588,13 +1715,9 @@ ${subSummaries.map((s, i) => `\n### [${i + 1}] ${s.name} (${s.root})\n${s.summar
|
|
|
1588
1715
|
finalName = `${projectName}架构说明`;
|
|
1589
1716
|
|
|
1590
1717
|
if (!finalSummary) {
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
name: finalName,
|
|
1595
|
-
template,
|
|
1596
|
-
result: '',
|
|
1597
|
-
content: template
|
|
1718
|
+
return res.status(502).json({
|
|
1719
|
+
success: false,
|
|
1720
|
+
error: '模型返回为空,请稍后重试'
|
|
1598
1721
|
});
|
|
1599
1722
|
}
|
|
1600
1723
|
|
|
@@ -2276,11 +2399,14 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
2276
2399
|
|
|
2277
2400
|
app.post('/api/workbench/tasks', async (req, res) => {
|
|
2278
2401
|
try {
|
|
2279
|
-
const { id, title, desc, promptId, subtasks, type: rawType, simpleOverride } = req.body || {};
|
|
2402
|
+
const { id, title, desc, promptId, subtasks, type: rawType, simpleOverride, sequential: rawSequential } = req.body || {};
|
|
2280
2403
|
// title 非必填:允许空字符串,UI 层会用"未命名任务"占位
|
|
2281
2404
|
const safeTitle = typeof title === 'string' ? title.trim() : '';
|
|
2282
2405
|
// type 归一化:仅接受 'simple' | 'complex',缺省/未知一律按 complex
|
|
2283
2406
|
const taskType = rawType === 'simple' ? 'simple' : 'complex';
|
|
2407
|
+
// sequential 归一化:仅接受 boolean,缺省/非布尔一律 true(连续模式),
|
|
2408
|
+
// 旧任务没字段时也是 true,保留连续默认行为
|
|
2409
|
+
const sequential = rawSequential === false ? false : true;
|
|
2284
2410
|
const safeOverride = typeof simpleOverride === 'string' ? simpleOverride.slice(0, 8000) : '';
|
|
2285
2411
|
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2286
2412
|
const tasks = data.tasks || [];
|
|
@@ -2296,6 +2422,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
2296
2422
|
desc: desc || '',
|
|
2297
2423
|
promptId: promptId || null,
|
|
2298
2424
|
type: taskType,
|
|
2425
|
+
// 复杂任务可显式关闭连续模式;简单任务无意义,固定 true
|
|
2426
|
+
sequential: taskType === 'complex' ? sequential : true,
|
|
2299
2427
|
simpleOverride: taskType === 'simple' ? safeOverride : '',
|
|
2300
2428
|
subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
|
|
2301
2429
|
id: s.id || genId(),
|
|
@@ -2329,6 +2457,8 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
2329
2457
|
desc: desc || '',
|
|
2330
2458
|
promptId: promptId || null,
|
|
2331
2459
|
type: taskType,
|
|
2460
|
+
// 新建任务:简单任务固定 true,复杂任务用传入值(默认 true)
|
|
2461
|
+
sequential: taskType === 'complex' ? sequential : true,
|
|
2332
2462
|
simpleOverride: taskType === 'simple' ? safeOverride : '',
|
|
2333
2463
|
projectPath: currentProjectPath || '',
|
|
2334
2464
|
subtasks: Array.isArray(subtasks) ? subtasks.map(s => ({
|
|
@@ -2530,7 +2660,11 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
2530
2660
|
// - 找到正在运行的 job,调 child.kill() 终止 claude 进程
|
|
2531
2661
|
// - Windows 下用 taskkill /T /F 杀进程树(claude 进程可能 fork 出子进程)
|
|
2532
2662
|
// - 加入 cancelledJobs 集合,runTaskQueue 退出循环后会把 job 标为 'cancelled'
|
|
2533
|
-
// -
|
|
2663
|
+
// - 同步把 sub.status 改为 'cancelled' 并 publish sub:update,
|
|
2664
|
+
// 否则前端 sub 列表/任务头黄点会一直显示 running(sub.status 没动)。
|
|
2665
|
+
// 历史这里只改 job.status 导致 UI 误显示「执行中」。
|
|
2666
|
+
// - 只影响这一个 sub;同 task 后续 sub 在 sequential=false 时继续执行,
|
|
2667
|
+
// sequential=true 时由 runTaskQueue 自然 break(见 runSingleSubtask 的 cancelled 分支)
|
|
2534
2668
|
app.post('/api/workbench/jobs/:id/cancel', (req, res) => {
|
|
2535
2669
|
const job = jobs.get(req.params.id)
|
|
2536
2670
|
if (!job) {
|
|
@@ -2545,6 +2679,9 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
2545
2679
|
job.error = '用户已停止执行'
|
|
2546
2680
|
job.endedAt = nowIso()
|
|
2547
2681
|
publish('job:update', { ...job }) // 用浅拷贝避免序列化 child 引用
|
|
2682
|
+
// 同步把 sub 也置 cancelled,否则 taskIsRunning(sub.status==='running') 一直 true
|
|
2683
|
+
// → 左侧任务头黄点 + 右侧 sub 列表 running 动效都不消失
|
|
2684
|
+
const subInfo = syncSubToCancelled(job)
|
|
2548
2685
|
// 终态:fire-and-forget 同步落盘,cancel 是显式操作,要保证不丢
|
|
2549
2686
|
flushJobsSaveNow().catch(err => console.warn('[workbench] jobs save failed:', err.message))
|
|
2550
2687
|
const child = job.child
|
package/src/utils/index.js
CHANGED
|
@@ -32,7 +32,7 @@ import stringWidth from 'string-width';
|
|
|
32
32
|
import Table from 'cli-table3';
|
|
33
33
|
import chalk from 'chalk';
|
|
34
34
|
import boxen from "boxen";
|
|
35
|
-
import {
|
|
35
|
+
import {execFile, execSync} from 'child_process'
|
|
36
36
|
import os from 'os'
|
|
37
37
|
import ora from "ora";
|
|
38
38
|
import readline from 'readline'
|
|
@@ -266,14 +266,18 @@ function clearCommandHistory() {
|
|
|
266
266
|
|
|
267
267
|
function execGitCommand(command, options = {}) {
|
|
268
268
|
return new Promise((resolve, reject) => {
|
|
269
|
-
let {encoding = 'utf-8', maxBuffer = 30 * 1024 * 1024, head = command, log = true} = options
|
|
269
|
+
let {encoding = 'utf-8', maxBuffer = 30 * 1024 * 1024, head = Array.isArray(command) ? command.join(' ') : command, log = true} = options
|
|
270
270
|
let cwd = getCwd()
|
|
271
|
-
|
|
271
|
+
|
|
272
272
|
// Record start time for command execution
|
|
273
273
|
const startTime = Date.now();
|
|
274
274
|
|
|
275
275
|
// setTimeout(() => {
|
|
276
|
-
|
|
276
|
+
// 用 execFile('git', argv) 跨平台执行:
|
|
277
|
+
// - 不走 shell,Windows cmd.exe / POSIX sh 都一致
|
|
278
|
+
// - argv 数组天然免疫 shell 注入,文件名/参数无需 shellQuote
|
|
279
|
+
// - 所有调用点约定只跑 git 子命令,见 src/utils/index.js 内的 execGitCommand 调用清单
|
|
280
|
+
execFile('git', command, {
|
|
277
281
|
env: {
|
|
278
282
|
...process.env,
|
|
279
283
|
// LANG: 'en_US.UTF-8', // Linux/macOS
|
|
@@ -282,7 +286,10 @@ function execGitCommand(command, options = {}) {
|
|
|
282
286
|
},
|
|
283
287
|
encoding,
|
|
284
288
|
maxBuffer,
|
|
285
|
-
cwd
|
|
289
|
+
cwd,
|
|
290
|
+
// Windows 下 git 是 .exe;POSIX 下直接 PATH 找。
|
|
291
|
+
// 不传 shell,杜绝 cmd.exe 单/双引号兼容问题与注入风险。
|
|
292
|
+
windowsHide: true
|
|
286
293
|
}, (error, stdout, stderr) => {
|
|
287
294
|
if (options.spinner) {
|
|
288
295
|
options.spinner.stop();
|
|
@@ -590,7 +597,7 @@ async function printGitLog() {
|
|
|
590
597
|
n = parseInt(logArg.split('=')[1], 10);
|
|
591
598
|
}
|
|
592
599
|
// 使用 ASCII 记录分隔符 %x1E 作为字段分隔符
|
|
593
|
-
const logCommand =
|
|
600
|
+
const logCommand = ['log', '-n', n, '--pretty=format:%C(green)%h%C(reset) %x1E %C(cyan)%an%C(reset) %x1E %C(yellow)%ad%C(reset) %x1E %C(blue)%D%C(reset) %x1E %C(magenta)%s%C(reset)', '--date=format:%Y-%m-%d %H:%M', '--graph', '--decorate', '--color']
|
|
594
601
|
try {
|
|
595
602
|
const logOutput = await execGitCommand(logCommand, {
|
|
596
603
|
head: `git log`
|
|
@@ -620,7 +627,7 @@ async function exec_push({exit, commitMessage}) {
|
|
|
620
627
|
// 执行 git push
|
|
621
628
|
const spinner = ora('正在推送代码...').start();
|
|
622
629
|
try {
|
|
623
|
-
const {stdout, stderr} = await execGitCommand('
|
|
630
|
+
const {stdout, stderr} = await execGitCommand(['push'], {
|
|
624
631
|
spinner
|
|
625
632
|
});
|
|
626
633
|
await printCommitLog({commitMessage});
|
|
@@ -633,15 +640,15 @@ async function exec_push({exit, commitMessage}) {
|
|
|
633
640
|
async function printCommitLog({commitMessage}) {
|
|
634
641
|
try {
|
|
635
642
|
// 获取项目名称(取git仓库根目录名)
|
|
636
|
-
const projectRootResult = await execGitCommand('
|
|
643
|
+
const projectRootResult = await execGitCommand(['rev-parse', '--show-toplevel'], {log: false});
|
|
637
644
|
const projectName = chalk.blueBright(path.basename(projectRootResult.stdout.trim()));
|
|
638
645
|
|
|
639
646
|
// 获取当前提交hash(取前7位)
|
|
640
|
-
const commitHashResult = await execGitCommand('
|
|
647
|
+
const commitHashResult = await execGitCommand(['rev-parse', '--short', 'HEAD'], {log: false});
|
|
641
648
|
const hashDisplay = chalk.yellow(commitHashResult.stdout.trim());
|
|
642
649
|
|
|
643
650
|
// 获取分支信息
|
|
644
|
-
const branchResult = await execGitCommand('
|
|
651
|
+
const branchResult = await execGitCommand(['branch', '--show-current'], {log: false});
|
|
645
652
|
const branchDisplay = chalk.magenta(branchResult.stdout.trim());
|
|
646
653
|
|
|
647
654
|
// 构建信息内容
|
|
@@ -679,7 +686,7 @@ async function execPull() {
|
|
|
679
686
|
try {
|
|
680
687
|
// 检查是否需要拉取更新
|
|
681
688
|
const spinner = ora('正在拉取代码...').start();
|
|
682
|
-
await execGitCommand('
|
|
689
|
+
await execGitCommand(['pull'], {
|
|
683
690
|
spinner
|
|
684
691
|
})
|
|
685
692
|
} catch (e) {
|
|
@@ -697,12 +704,12 @@ async function judgeRemote() {
|
|
|
697
704
|
try {
|
|
698
705
|
// 检查是否有远程更新
|
|
699
706
|
// 先获取远程最新状态
|
|
700
|
-
await execGitCommand('
|
|
707
|
+
await execGitCommand(['remote', 'update'], {
|
|
701
708
|
head: 'Fetching remote updates',
|
|
702
709
|
log: false
|
|
703
710
|
});
|
|
704
711
|
// 检查是否需要 pull
|
|
705
|
-
const res = await execGitCommand('
|
|
712
|
+
const res = await execGitCommand(['rev-list', 'HEAD..@{u}', '--count'], {
|
|
706
713
|
head: 'Checking if behind remote',
|
|
707
714
|
log: false
|
|
708
715
|
});
|
|
@@ -754,7 +761,7 @@ async function judgeRemote() {
|
|
|
754
761
|
async function execDiff() {
|
|
755
762
|
const no_diff = process.argv.find(arg => arg.startsWith('--no-diff'))
|
|
756
763
|
if (!no_diff) {
|
|
757
|
-
await execGitCommand('
|
|
764
|
+
await execGitCommand(['diff', '--color=always'], {
|
|
758
765
|
head: `git diff`
|
|
759
766
|
})
|
|
760
767
|
}
|
|
@@ -768,14 +775,14 @@ async function execGitAddWithLockFilter() {
|
|
|
768
775
|
|
|
769
776
|
if (lockedFiles.length === 0) {
|
|
770
777
|
// 如果没有锁定文件,直接执行 git add .
|
|
771
|
-
await execGitCommand('
|
|
778
|
+
await execGitCommand(['add', '.']);
|
|
772
779
|
return;
|
|
773
780
|
}
|
|
774
781
|
|
|
775
782
|
// 获取Git工作目录根路径,确保路径匹配的准确性
|
|
776
783
|
let gitRoot;
|
|
777
784
|
try {
|
|
778
|
-
const gitRootResult = await execGitCommand('
|
|
785
|
+
const gitRootResult = await execGitCommand(['rev-parse', '--show-toplevel'], {log: false});
|
|
779
786
|
gitRoot = path.normalize(gitRootResult.stdout.trim());
|
|
780
787
|
} catch (error) {
|
|
781
788
|
console.warn(chalk.yellow('⚠️ 无法获取Git根目录,使用当前工作目录'));
|
|
@@ -783,7 +790,7 @@ async function execGitAddWithLockFilter() {
|
|
|
783
790
|
}
|
|
784
791
|
|
|
785
792
|
// 获取所有修改的文件(包括未跟踪文件)
|
|
786
|
-
const statusResult = await execGitCommand('
|
|
793
|
+
const statusResult = await execGitCommand(['status', '--porcelain', '--untracked-files=all'], {log: false});
|
|
787
794
|
const modifiedFiles = statusResult.stdout
|
|
788
795
|
.split('\n')
|
|
789
796
|
.filter(line => line.trim())
|
|
@@ -896,19 +903,15 @@ async function execGitAddWithLockFilter() {
|
|
|
896
903
|
}
|
|
897
904
|
|
|
898
905
|
// 逐个添加未锁定的文件
|
|
899
|
-
//
|
|
900
|
-
//
|
|
901
|
-
//
|
|
902
|
-
//
|
|
903
|
-
const shellQuote = (s) => {
|
|
904
|
-
if (s === null || s === undefined) return "''"
|
|
905
|
-
return `'${String(s).replace(/'/g, `'\\''`)}'`
|
|
906
|
-
}
|
|
906
|
+
// 用 execFile('git', argv) 直接传 argv 数组,不经过 shell,
|
|
907
|
+
// 文件名含空格/特殊字符时不需要 shellQuote;同时跨 Windows cmd.exe /
|
|
908
|
+
// POSIX sh 都一致(此前单引号写法在 Windows cmd.exe 不被识别,导致
|
|
909
|
+
// "pathspec '...did not match any files" 报错,见 da01bcb 回归)。
|
|
907
910
|
let successCount = 0
|
|
908
911
|
let failedFiles = []
|
|
909
912
|
for (const file of filesToAdd) {
|
|
910
913
|
try {
|
|
911
|
-
await execGitCommand(
|
|
914
|
+
await execGitCommand(['add', '--', file], {
|
|
912
915
|
head: `git add ${file}`,
|
|
913
916
|
log: false
|
|
914
917
|
})
|
|
@@ -979,7 +982,7 @@ async function execAddAndCommit({statusOutput, commitMessage, exit}) {
|
|
|
979
982
|
}
|
|
980
983
|
|
|
981
984
|
// 提交前二次校验(包括未跟踪文件)
|
|
982
|
-
const checkStatus = await execGitCommand('
|
|
985
|
+
const checkStatus = await execGitCommand(['status', '--porcelain', '--untracked-files=all'], {log: false});
|
|
983
986
|
if (!checkStatus.stdout.trim()) {
|
|
984
987
|
console.log(chalk.yellow('⚠️ 没有检测到可提交的变更'));
|
|
985
988
|
// exec_exit(exit)
|
|
@@ -988,7 +991,7 @@ async function execAddAndCommit({statusOutput, commitMessage, exit}) {
|
|
|
988
991
|
|
|
989
992
|
// 执行 git commit
|
|
990
993
|
if (statusOutput.includes('Untracked files:') || statusOutput.includes('Changes not staged for commit') || statusOutput.includes('Changes to be committed')) {
|
|
991
|
-
await execGitCommand(
|
|
994
|
+
await execGitCommand(['commit', '-m', commitMessage])
|
|
992
995
|
}
|
|
993
996
|
|
|
994
997
|
// 返回实际使用的提交信息
|
|
@@ -1053,7 +1056,7 @@ async function addResetScriptToPackageJson() {
|
|
|
1053
1056
|
}
|
|
1054
1057
|
|
|
1055
1058
|
// 获取当前分支名
|
|
1056
|
-
const branchResult = await execGitCommand('
|
|
1059
|
+
const branchResult = await execGitCommand(['branch', '--show-current'], {log: false});
|
|
1057
1060
|
const branch = branchResult.stdout.trim();
|
|
1058
1061
|
|
|
1059
1062
|
// 添加 g:reset 命令
|