zen-gitsync 2.13.15 → 2.13.16
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 +6 -3
- package/src/ui/public/assets/{EditorView-nLJuJf8I.js → EditorView-CRpjzBS1.js} +0 -0
- package/src/ui/public/assets/{SourceMapView-BfQkRIld.js → SourceMapView-ByHkwMwM.js} +1 -1
- package/src/ui/public/assets/WorkbenchView-Dhle7BQe.css +1 -0
- package/src/ui/public/assets/WorkbenchView-cBXTpeWC.js +9 -0
- package/src/ui/public/assets/{_plugin-vue_export-helper-FEqtfvX2.js → _plugin-vue_export-helper-CT67BnrS.js} +3 -3
- package/src/ui/public/assets/{index-YZlo31ni.js → index-C19XAWFW.js} +16 -15
- package/src/ui/public/assets/index-C3VghkAQ.css +1 -0
- package/src/ui/public/assets/{vendor-1pu0KtT-.js → vendor-D72Z0Jmi.js} +90 -90
- package/src/ui/public/assets/{vendor-B99WlFa_.css → vendor-F3eVqHe1.css} +1 -1
- package/src/ui/public/index.html +5 -5
- package/src/ui/server/index.js +2 -1
- package/src/ui/server/routes/exec.js +10 -9
- package/src/ui/server/routes/status.js +16 -17
- package/src/ui/server/routes/terminal.js +12 -13
- package/src/ui/server/routes/workbench.js +207 -12
- package/src/ui/server/utils/shellQuote.js +68 -0
- package/src/ui/server/utils/shellQuote.test.js +110 -0
- package/src/utils/index.js +60 -29
- package/src/utils/parseCwdArg.test.js +86 -0
- package/src/ui/public/assets/WorkbenchView-DBm4y9hO.css +0 -1
- package/src/ui/public/assets/WorkbenchView-DadqVl2m.js +0 -9
- package/src/ui/public/assets/index-BB8LQ04I.css +0 -1
package/src/ui/public/index.html
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-C19XAWFW.js"></script>
|
|
14
14
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CMxvf4Kt.js">
|
|
15
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-
|
|
16
|
-
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/vendor-
|
|
18
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
15
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-D72Z0Jmi.js">
|
|
16
|
+
<link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-CT67BnrS.js">
|
|
17
|
+
<link rel="stylesheet" crossorigin href="/assets/vendor-F3eVqHe1.css">
|
|
18
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C3VghkAQ.css">
|
|
19
19
|
</head>
|
|
20
20
|
<body>
|
|
21
21
|
<div id="app"></div>
|
package/src/ui/server/index.js
CHANGED
|
@@ -134,7 +134,8 @@ async function startUIServer(noOpen = false, savePort = false) {
|
|
|
134
134
|
execGitCommand,
|
|
135
135
|
addCommandToHistory,
|
|
136
136
|
getCurrentProjectPath: () => currentProjectPath,
|
|
137
|
-
|
|
137
|
+
// 用 pre-increment 从 1 起,避免首进程拿到 id=0(0 在 `if (processId)` 这类真值检查里会被吞)
|
|
138
|
+
nextProcessId: () => ++processIdCounter,
|
|
138
139
|
runningProcesses
|
|
139
140
|
});
|
|
140
141
|
|
|
@@ -123,20 +123,21 @@ export function registerExecRoutes({
|
|
|
123
123
|
// npm、node、git 等现代工具都输出 UTF-8
|
|
124
124
|
const isWindows = process.platform === 'win32';
|
|
125
125
|
const cmdBuiltins = ['dir', 'type', 'set', 'path', 'cd', 'md', 'rd', 'del', 'copy', 'move', 'ren'];
|
|
126
|
-
|
|
126
|
+
|
|
127
127
|
// echo 命令特殊处理:如果包含变量替换(如 {{xxx}}),说明内容可能已经是 UTF-8,不转换
|
|
128
|
-
const
|
|
128
|
+
const trimmed = command.trim().toLowerCase();
|
|
129
|
+
const firstToken = trimmed.split(/\s+/, 1)[0] || '';
|
|
130
|
+
const isEchoCommand = firstToken === 'echo';
|
|
129
131
|
const hasVariableSubstitution = isEchoCommand && (
|
|
130
|
-
command.includes('{{') ||
|
|
131
|
-
command.includes('${') ||
|
|
132
|
+
command.includes('{{') ||
|
|
133
|
+
command.includes('${') ||
|
|
132
134
|
command.includes('%') // Windows 环境变量
|
|
133
135
|
);
|
|
134
|
-
|
|
136
|
+
|
|
137
|
+
// 必须按 token 边界判断,避免 'dir' 误匹配 'directory'、'path' 误匹配 'path-to'
|
|
135
138
|
const needsGbkConversion = isWindows && (
|
|
136
|
-
cmdBuiltins.
|
|
137
|
-
|
|
138
|
-
command.trim().toLowerCase() === builtin
|
|
139
|
-
) || (isEchoCommand && !hasVariableSubstitution) // echo 只在没有变量替换时才转换
|
|
139
|
+
cmdBuiltins.includes(firstToken) ||
|
|
140
|
+
(isEchoCommand && !hasVariableSubstitution) // echo 只在没有变量替换时才转换
|
|
140
141
|
);
|
|
141
142
|
|
|
142
143
|
console.log(`[流式输出] 命令: ${command.substring(0, 50)}, 需要GBK转换: ${needsGbkConversion}`);
|
|
@@ -33,32 +33,31 @@ export function registerStatusRoutes({
|
|
|
33
33
|
app.get('/api/status_porcelain', async (req, res) => {
|
|
34
34
|
try {
|
|
35
35
|
const { stdout } = await execGitCommand('git status --porcelain --untracked-files=all');
|
|
36
|
-
// 检测是否处于 MERGING 状态(MERGE_HEAD
|
|
36
|
+
// 检测是否处于 MERGING 状态(MERGE_HEAD 存在)
|
|
37
37
|
let isMergeInProgress = false;
|
|
38
38
|
let mergeMessage = '';
|
|
39
39
|
try {
|
|
40
40
|
const { stdout: mergeHead } = await execGitCommand('git rev-parse -q --verify MERGE_HEAD');
|
|
41
41
|
isMergeInProgress = mergeHead.trim().length > 0;
|
|
42
|
-
if (isMergeInProgress) {
|
|
43
|
-
// 读取 Git 自动生成的合并提交信息
|
|
44
|
-
try {
|
|
45
|
-
const { stdout: gitDir } = await execGitCommand('git rev-parse --git-dir');
|
|
46
|
-
const mergeMsgPath = path.resolve(gitDir.trim(), 'MERGE_MSG');
|
|
47
|
-
const raw = await fs.readFile(mergeMsgPath, 'utf-8');
|
|
48
|
-
// 过滤掉以 # 开头的注释行,取第一个非空行
|
|
49
|
-
mergeMessage = raw
|
|
50
|
-
.split('\n')
|
|
51
|
-
.filter(line => line.trim() && !line.startsWith('#'))
|
|
52
|
-
.join('\n')
|
|
53
|
-
.trim();
|
|
54
|
-
} catch (_) {
|
|
55
|
-
// MERGE_MSG 不存在时忽略
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
42
|
} catch (_) {
|
|
59
43
|
// MERGE_HEAD 不存在时命令会报错,属正常情况
|
|
60
44
|
isMergeInProgress = false;
|
|
61
45
|
}
|
|
46
|
+
if (isMergeInProgress) {
|
|
47
|
+
try {
|
|
48
|
+
const { stdout: gitDir } = await execGitCommand('git rev-parse --git-dir');
|
|
49
|
+
const mergeMsgPath = path.resolve(gitDir.trim(), 'MERGE_MSG');
|
|
50
|
+
const raw = await fs.readFile(mergeMsgPath, 'utf-8');
|
|
51
|
+
// 过滤掉以 # 开头的注释行,拼接所有非空非注释行(保留多段信息如合并分支列表)
|
|
52
|
+
mergeMessage = raw
|
|
53
|
+
.split('\n')
|
|
54
|
+
.filter(line => line.trim() && !line.startsWith('#'))
|
|
55
|
+
.join('\n')
|
|
56
|
+
.trim();
|
|
57
|
+
} catch (_) {
|
|
58
|
+
// MERGE_MSG 不存在/无权限时忽略
|
|
59
|
+
}
|
|
60
|
+
}
|
|
62
61
|
res.json({ status: stdout, isMergeInProgress, mergeMessage });
|
|
63
62
|
} catch (error) {
|
|
64
63
|
res.status(500).json({ error: error.message });
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
//
|
|
15
15
|
import { spawn, exec } from 'child_process';
|
|
16
|
+
import { psEscape, appleEscape, shQuote } from '../utils/shellQuote.js';
|
|
16
17
|
|
|
17
18
|
export function registerTerminalRoutes({
|
|
18
19
|
app,
|
|
@@ -25,7 +26,6 @@ export function registerTerminalRoutes({
|
|
|
25
26
|
|
|
26
27
|
if (process.platform === 'win32') {
|
|
27
28
|
const cmdToRun = String(command || '').trim();
|
|
28
|
-
const safeWorkingDir = String(targetDir).replace(/"/g, '""');
|
|
29
29
|
|
|
30
30
|
const splitArgs = (input) => {
|
|
31
31
|
const s = String(input || '');
|
|
@@ -59,6 +59,7 @@ export function registerTerminalRoutes({
|
|
|
59
59
|
const tokens = splitArgs(cmdToRun);
|
|
60
60
|
const isStartCommand = tokens.length > 0 && String(tokens[0]).toLowerCase() === 'start';
|
|
61
61
|
|
|
62
|
+
// PowerShell 转义全部走 shellQuote.psEscape ($ ` " 都会被处理)
|
|
62
63
|
let psScript;
|
|
63
64
|
if (isStartCommand) {
|
|
64
65
|
const args = tokens.slice(1);
|
|
@@ -69,19 +70,14 @@ export function registerTerminalRoutes({
|
|
|
69
70
|
const exe = candidateExe && !isUrl(candidateExe) ? String(candidateExe) : null;
|
|
70
71
|
|
|
71
72
|
if (!exe && candidateUrl) {
|
|
72
|
-
|
|
73
|
-
psScript = `$p = Start-Process -FilePath "${safeUrl}" -WorkingDirectory "${safeWorkingDir}" -PassThru; Write-Output $p.Id`;
|
|
73
|
+
psScript = `$p = Start-Process -FilePath "${psEscape(candidateUrl)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
|
|
74
74
|
} else if (exe && candidateUrl) {
|
|
75
|
-
|
|
76
|
-
const safeUrl = String(candidateUrl).replace(/"/g, '""');
|
|
77
|
-
psScript = `$p = Start-Process -FilePath "${safeExe}" -ArgumentList "${safeUrl}" -WorkingDirectory "${safeWorkingDir}" -PassThru; Write-Output $p.Id`;
|
|
75
|
+
psScript = `$p = Start-Process -FilePath "${psEscape(exe)}" -ArgumentList "${psEscape(candidateUrl)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
|
|
78
76
|
} else {
|
|
79
|
-
|
|
80
|
-
psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/C", "${safeCmd}" -WorkingDirectory "${safeWorkingDir}" -WindowStyle Hidden -PassThru; Write-Output $p.Id`;
|
|
77
|
+
psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/C", "${psEscape(cmdToRun)}" -WorkingDirectory "${psEscape(targetDir)}" -WindowStyle Hidden -PassThru; Write-Output $p.Id`;
|
|
81
78
|
}
|
|
82
79
|
} else {
|
|
83
|
-
|
|
84
|
-
psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/K", "${safeCmd}" -WorkingDirectory "${safeWorkingDir}" -PassThru; Write-Output $p.Id`;
|
|
80
|
+
psScript = `$p = Start-Process -FilePath "cmd.exe" -ArgumentList "/K", "${psEscape(cmdToRun)}" -WorkingDirectory "${psEscape(targetDir)}" -PassThru; Write-Output $p.Id`;
|
|
85
81
|
}
|
|
86
82
|
|
|
87
83
|
return await new Promise((resolve, reject) => {
|
|
@@ -118,8 +114,10 @@ export function registerTerminalRoutes({
|
|
|
118
114
|
}
|
|
119
115
|
|
|
120
116
|
if (process.platform === 'darwin') {
|
|
121
|
-
|
|
122
|
-
|
|
117
|
+
// AppleScript 转义统一走 shellQuote.appleEscape (\ " 都会被处理)
|
|
118
|
+
const script = `tell application "Terminal" to do script "cd ${appleEscape(targetDir)} && ${appleEscape(command.trim())}"`;
|
|
119
|
+
// 外层用单引号包,把脚本里可能出现的单引号转义为 '\''
|
|
120
|
+
exec(`osascript -e ${shQuote(script)}`, (error) => {
|
|
123
121
|
if (error) {
|
|
124
122
|
console.error('打开终端失败:', error);
|
|
125
123
|
}
|
|
@@ -127,7 +125,8 @@ export function registerTerminalRoutes({
|
|
|
127
125
|
return { pid: null };
|
|
128
126
|
}
|
|
129
127
|
|
|
130
|
-
|
|
128
|
+
// Linux: 用 sh 单引号包命令行,单引号内不解释 $ ` " \ ,只把单引号自身 '\''
|
|
129
|
+
const terminalCommand = `gnome-terminal -- bash -c ${shQuote(`cd ${targetDir} && ${command.trim()}; exec bash`)} || xterm -e ${shQuote(`cd ${targetDir} && ${command.trim()}; bash`)}`;
|
|
131
130
|
exec(terminalCommand, (error) => {
|
|
132
131
|
if (error) {
|
|
133
132
|
console.error('打开终端失败:', error);
|
|
@@ -1070,20 +1070,35 @@ function launchClaudeInNewWindow(cwd, promptText, resumeSessionId) {
|
|
|
1070
1070
|
});
|
|
1071
1071
|
}
|
|
1072
1072
|
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1073
|
+
/**
|
|
1074
|
+
* 顺序执行一个任务下所有子任务;上一个结束再启动下一个。
|
|
1075
|
+
* 入参 fromIndex 指定从哪个 sub 开始(0-based,默认 0),用于"从此处开始"入口;
|
|
1076
|
+
* fromIndex>0 时,把 [0, fromIndex) 区间内已 done 的 sub 输出预填到 priorOutputs,
|
|
1077
|
+
* 这样后续 sub 仍能拿到前序上下文(跟单 sub 执行的语义保持一致)。
|
|
1078
|
+
*/
|
|
1079
|
+
async function runTaskQueue(task, repoPath, branch, opts) {
|
|
1080
|
+
// 前序上下文:跑完一个 sub 后把它"完成态"摘要存到这里,下一个 sub 启动时
|
|
1081
|
+
// 拼到 prompt 头部,让 Claude 知道前面做了什么、产出了什么。
|
|
1082
|
+
// 故意不用 raw output 全文——LLM 已经习惯"摘要 + 关键结论"的格式,且不会
|
|
1078
1083
|
// 一次塞几 MB 进 prompt 烧 token。truncate 到每条 MAX_PREV_OUTPUT_CHARS。
|
|
1079
1084
|
const MAX_PREV_OUTPUT_CHARS = 2000
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1085
|
+
const requested = Number(opts && opts.fromIndex)
|
|
1086
|
+
const fromIndex = Number.isInteger(requested) && requested >= 0 && requested < task.subtasks.length
|
|
1087
|
+
? requested
|
|
1088
|
+
: 0
|
|
1089
|
+
// 从 fromIndex 开始时,把前面已 done 的 sub 输出预填进 priorOutputs,
|
|
1090
|
+
// 否则"从中间开始"会丢失前序上下文。
|
|
1091
|
+
const priorOutputs = fromIndex > 0
|
|
1092
|
+
? await collectPriorOutputsUpTo(task, fromIndex)
|
|
1093
|
+
: []
|
|
1094
|
+
for (let i = fromIndex; i < task.subtasks.length; i++) {
|
|
1095
|
+
const sub = task.subtasks[i]
|
|
1082
1096
|
if (sub.status === 'done') continue;
|
|
1083
1097
|
await runSingleSubtask(task, sub, repoPath, branch, priorOutputs)
|
|
1098
|
+
// 逐 sub 落盘:之前只在队列跑完才 persistTaskAfterRun,中途崩溃会丢已完成
|
|
1099
|
+
// sub 的状态。runSingleSubtask 已经把 sub.status 改完,这里补一次落盘。
|
|
1100
|
+
await persistTaskAfterRun(task)
|
|
1084
1101
|
}
|
|
1085
|
-
// 写回 tasks.json
|
|
1086
|
-
await persistTaskAfterRun(task)
|
|
1087
1102
|
}
|
|
1088
1103
|
|
|
1089
1104
|
/**
|
|
@@ -1324,11 +1339,19 @@ async function persistTaskAfterRun(task) {
|
|
|
1324
1339
|
* 子任务输出摘要收集起来。这样单独跑一个 sub 时,它也能拿到前序上下文。
|
|
1325
1340
|
*/
|
|
1326
1341
|
async function collectPriorOutputs(task, targetSub) {
|
|
1342
|
+
const targetIdx = task.subtasks.findIndex(s => s.id === targetSub.id)
|
|
1343
|
+
if (targetIdx < 0) return []
|
|
1344
|
+
return collectPriorOutputsUpTo(task, targetIdx)
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* 收集 [0, endIdx) 区间内已 done 的 sub 输出摘要,作为队列内 sub 的前序上下文。
|
|
1349
|
+
* runTaskQueue 在 fromIndex>0 时调这个,让"从此处开始"也能拼上前序 done sub 的结论。
|
|
1350
|
+
*/
|
|
1351
|
+
async function collectPriorOutputsUpTo(task, endIdx) {
|
|
1327
1352
|
const MAX_PREV_OUTPUT_CHARS = 2000
|
|
1328
1353
|
const prior = []
|
|
1329
|
-
|
|
1330
|
-
if (targetIdx < 0) return prior
|
|
1331
|
-
for (let i = 0; i < targetIdx; i++) {
|
|
1354
|
+
for (let i = 0; i < endIdx; i++) {
|
|
1332
1355
|
const s = task.subtasks[i]
|
|
1333
1356
|
if (s.status !== 'done') continue
|
|
1334
1357
|
// 从 jobs 列表里找最近一个属于这个 sub 且 status=done 的 job,
|
|
@@ -2305,6 +2328,40 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
2305
2328
|
}
|
|
2306
2329
|
});
|
|
2307
2330
|
|
|
2331
|
+
// ── 从指定子任务开始执行 ───────────────────────────────────────────
|
|
2332
|
+
// POST /api/workbench/tasks/:id/run-from
|
|
2333
|
+
// 入参 body: { startSubIndex: number }
|
|
2334
|
+
// 行为:
|
|
2335
|
+
// - 从 task.subtasks[startSubIndex] 开始按序执行,前面 [0, startSubIndex) 区间的
|
|
2336
|
+
// done sub 输出会自动作为前序上下文(同单 sub 执行的语义)
|
|
2337
|
+
// - 前面 error / running 的 sub 不会被自动重跑;从 startSubIndex 开始重新走队列
|
|
2338
|
+
// - 适合"中间某个 sub 失败/被取消后,从该 sub 继续"的场景
|
|
2339
|
+
// - startSubIndex 必须落在 [0, task.subtasks.length) 范围内
|
|
2340
|
+
app.post('/api/workbench/tasks/:id/run-from', async (req, res) => {
|
|
2341
|
+
try {
|
|
2342
|
+
const data = await readJson(TASKS_FILE, { tasks: [] });
|
|
2343
|
+
const task = (data.tasks || []).find(t => t.id === req.params.id);
|
|
2344
|
+
if (!task) return res.status(404).json({ success: false, error: '任务不存在' });
|
|
2345
|
+
if (!task.subtasks || task.subtasks.length === 0) {
|
|
2346
|
+
return res.status(400).json({ success: false, error: '任务没有子任务' });
|
|
2347
|
+
}
|
|
2348
|
+
const startSubIndex = Number(req.body?.startSubIndex);
|
|
2349
|
+
if (!Number.isInteger(startSubIndex)
|
|
2350
|
+
|| startSubIndex < 0
|
|
2351
|
+
|| startSubIndex >= task.subtasks.length) {
|
|
2352
|
+
return res.status(400).json({ success: false, error: 'startSubIndex 越界' });
|
|
2353
|
+
}
|
|
2354
|
+
const repoPath = typeof getCurrentProjectPath === 'function' ? getCurrentProjectPath() : '';
|
|
2355
|
+
// 异步执行,立即返回(同 /run 的 fire-and-forget 模式)
|
|
2356
|
+
res.json({ success: true, message: `已从第 ${startSubIndex + 1} 个子任务开始执行` });
|
|
2357
|
+
runTaskQueue(task, repoPath, '', { fromIndex: startSubIndex }).catch(err => {
|
|
2358
|
+
publish('task:error', { taskId: task.id, error: err.message });
|
|
2359
|
+
});
|
|
2360
|
+
} catch (err) {
|
|
2361
|
+
res.status(500).json({ success: false, error: err.message });
|
|
2362
|
+
}
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2308
2365
|
// ── 执行简单任务(无子任务直接跑) ──────────────────────────────────
|
|
2309
2366
|
// POST /api/workbench/tasks/:id/run-simple
|
|
2310
2367
|
// 行为:
|
|
@@ -2672,6 +2729,144 @@ ${desc ? `描述:${desc}` : '描述:(无)'}${attachmentBlock}${templateB
|
|
|
2672
2729
|
}
|
|
2673
2730
|
})
|
|
2674
2731
|
|
|
2732
|
+
// POST /api/workbench/tasks/:id/clear-execution
|
|
2733
|
+
// 一次性清空指定 task 的所有执行痕迹:
|
|
2734
|
+
// 1. 删除内存 + 磁盘 jobs.json 中该 task 的全部 job(跳过 running/pending)
|
|
2735
|
+
// 2. 把 tasks.json 中该 task 的所有 subtasks status 重置为 'todo'(让"重新执行"能从头跑)
|
|
2736
|
+
// 3. 同步清掉 sub 的 error 字段,broadcast task:update + 一组 sub:update
|
|
2737
|
+
// 任务描述、附件、提示词模板等"内容"字段保留不动——只清"执行痕迹"。
|
|
2738
|
+
// 活跃态(有 running/pending job)时拒绝,避免误杀正在跑的实例。
|
|
2739
|
+
app.post('/api/workbench/tasks/:id/clear-execution', async (req, res) => {
|
|
2740
|
+
try {
|
|
2741
|
+
const taskId = req.params.id
|
|
2742
|
+
if (!taskId) return res.status(400).json({ success: false, error: '缺少 taskId' })
|
|
2743
|
+
// 检查活跃 job
|
|
2744
|
+
const live = []
|
|
2745
|
+
for (const j of jobs.values()) {
|
|
2746
|
+
if (j.taskId !== taskId) continue
|
|
2747
|
+
if (j.status === 'running' || j.status === 'pending') live.push(j.id)
|
|
2748
|
+
}
|
|
2749
|
+
if (live.length > 0) {
|
|
2750
|
+
return res.status(400).json({ success: false, error: `有 ${live.length} 个 job 正在执行,请先停止` })
|
|
2751
|
+
}
|
|
2752
|
+
// 1) 清空 jobs(内存 + 磁盘)
|
|
2753
|
+
const removedJobIds = []
|
|
2754
|
+
for (const j of jobs.values()) {
|
|
2755
|
+
if (j.taskId !== taskId) continue
|
|
2756
|
+
jobs.delete(j.id)
|
|
2757
|
+
removedJobIds.push(j.id)
|
|
2758
|
+
}
|
|
2759
|
+
const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
2760
|
+
if (jobsData && Array.isArray(jobsData.jobs)) {
|
|
2761
|
+
const before = jobsData.jobs.length
|
|
2762
|
+
jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId)
|
|
2763
|
+
if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData)
|
|
2764
|
+
}
|
|
2765
|
+
// 2) 重置 subtasks.status → todo
|
|
2766
|
+
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
2767
|
+
const task = (tasksData.tasks || []).find(t => t.id === taskId)
|
|
2768
|
+
if (!task) {
|
|
2769
|
+
return res.json({ success: true, removedJobs: removedJobIds.length, resetSubs: 0, message: '任务不存在,仅清空 job' })
|
|
2770
|
+
}
|
|
2771
|
+
let resetSubs = 0
|
|
2772
|
+
if (Array.isArray(task.subtasks)) {
|
|
2773
|
+
for (const s of task.subtasks) {
|
|
2774
|
+
if (s.status !== 'todo') {
|
|
2775
|
+
s.status = 'todo'
|
|
2776
|
+
resetSubs++
|
|
2777
|
+
if (s.error) delete s.error
|
|
2778
|
+
publish('sub:update', { taskId, sub: s })
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
task.updatedAt = nowIso()
|
|
2782
|
+
await writeJson(TASKS_FILE, tasksData)
|
|
2783
|
+
publish('task:update', task)
|
|
2784
|
+
}
|
|
2785
|
+
res.json({
|
|
2786
|
+
success: true,
|
|
2787
|
+
removedJobs: removedJobIds.length,
|
|
2788
|
+
resetSubs,
|
|
2789
|
+
message: `已清空 ${removedJobIds.length} 条执行记录,${resetSubs} 个子任务重置为待执行`
|
|
2790
|
+
})
|
|
2791
|
+
} catch (err) {
|
|
2792
|
+
res.status(500).json({ success: false, error: '清空执行痕迹失败: ' + (err.message || String(err)) })
|
|
2793
|
+
}
|
|
2794
|
+
})
|
|
2795
|
+
|
|
2796
|
+
// POST /api/workbench/tasks/:id/reset-shell
|
|
2797
|
+
// 把 task 还原成"只有标题的空壳",清掉 desc / attachments / promptId / subtasks。
|
|
2798
|
+
// 与 clear-execution 的区别:
|
|
2799
|
+
// - clear-execution 只重置 sub.status + 清 jobs(保留拆分/描述/附件)
|
|
2800
|
+
// - reset-shell 直接删除 subtasks 数组和描述/附件,任务只剩标题
|
|
2801
|
+
// 同时清空该 task 的所有 job(沿用 by-task 删除语义,跳过 running/pending)。
|
|
2802
|
+
// 活跃态拒绝,避免误杀正在跑的实例。
|
|
2803
|
+
app.post('/api/workbench/tasks/:id/reset-shell', async (req, res) => {
|
|
2804
|
+
try {
|
|
2805
|
+
const taskId = req.params.id
|
|
2806
|
+
if (!taskId) return res.status(400).json({ success: false, error: '缺少 taskId' })
|
|
2807
|
+
// 检查活跃 job
|
|
2808
|
+
const live = []
|
|
2809
|
+
for (const j of jobs.values()) {
|
|
2810
|
+
if (j.taskId !== taskId) continue
|
|
2811
|
+
if (j.status === 'running' || j.status === 'pending') live.push(j.id)
|
|
2812
|
+
}
|
|
2813
|
+
if (live.length > 0) {
|
|
2814
|
+
return res.status(400).json({ success: false, error: `有 ${live.length} 个 job 正在执行,请先停止` })
|
|
2815
|
+
}
|
|
2816
|
+
// 1) 清空 jobs(内存 + 磁盘)
|
|
2817
|
+
const removedJobIds = []
|
|
2818
|
+
for (const j of jobs.values()) {
|
|
2819
|
+
if (j.taskId !== taskId) continue
|
|
2820
|
+
jobs.delete(j.id)
|
|
2821
|
+
removedJobIds.push(j.id)
|
|
2822
|
+
}
|
|
2823
|
+
const jobsData = await readJson(JOBS_FILE, { version: 1, jobs: [] })
|
|
2824
|
+
if (jobsData && Array.isArray(jobsData.jobs)) {
|
|
2825
|
+
const before = jobsData.jobs.length
|
|
2826
|
+
jobsData.jobs = jobsData.jobs.filter(j => j.taskId !== taskId)
|
|
2827
|
+
if (jobsData.jobs.length !== before) await writeJson(JOBS_FILE, jobsData)
|
|
2828
|
+
}
|
|
2829
|
+
// 2) 重置 task 字段:只留 id / title / createdAt / status / projectPath(如存在)
|
|
2830
|
+
const tasksData = await readJson(TASKS_FILE, { tasks: [] })
|
|
2831
|
+
const task = (tasksData.tasks || []).find(t => t.id === taskId)
|
|
2832
|
+
if (!task) {
|
|
2833
|
+
return res.json({ success: true, removedJobs: removedJobIds.length, message: '任务不存在,仅清空 job' })
|
|
2834
|
+
}
|
|
2835
|
+
const removedSubCount = Array.isArray(task.subtasks) ? task.subtasks.length : 0
|
|
2836
|
+
const removedAttCount = Array.isArray(task.attachments) ? task.attachments.length : 0
|
|
2837
|
+
const hadDesc = !!(task.desc && task.desc.length > 0)
|
|
2838
|
+
const hadPrompt = !!task.promptId
|
|
2839
|
+
// 保留字段:id / title / createdAt / status / projectPath
|
|
2840
|
+
// 清理:desc / attachments / promptId / subtasks
|
|
2841
|
+
const preservedProjectPath = task.projectPath
|
|
2842
|
+
const preservedCreatedAt = task.createdAt
|
|
2843
|
+
const preservedStatus = task.status || 'todo'
|
|
2844
|
+
// 重建对象(顺序保留 title 在前,符合视觉习惯)
|
|
2845
|
+
const newTask = { id: task.id, title: task.title, status: preservedStatus }
|
|
2846
|
+
if (preservedProjectPath) newTask.projectPath = preservedProjectPath
|
|
2847
|
+
if (preservedCreatedAt) newTask.createdAt = preservedCreatedAt
|
|
2848
|
+
newTask.subtasks = []
|
|
2849
|
+
newTask.attachments = []
|
|
2850
|
+
newTask.updatedAt = nowIso()
|
|
2851
|
+
// 用 Object.assign 替换 task 的所有自身属性,保留原型链
|
|
2852
|
+
for (const k of Object.keys(task)) delete task[k]
|
|
2853
|
+
Object.assign(task, newTask)
|
|
2854
|
+
await writeJson(TASKS_FILE, tasksData)
|
|
2855
|
+
publish('task:update', task)
|
|
2856
|
+
res.json({
|
|
2857
|
+
success: true,
|
|
2858
|
+
removedJobs: removedJobIds.length,
|
|
2859
|
+
removedSubs: removedSubCount,
|
|
2860
|
+
removedAttachments: removedAttCount,
|
|
2861
|
+
clearedDesc: hadDesc,
|
|
2862
|
+
clearedPrompt: hadPrompt,
|
|
2863
|
+
message: `已清空:删除 ${removedSubCount} 个子任务${removedAttCount ? '、' + removedAttCount + ' 个附件' : ''}${hadDesc ? '、任务描述' : ''},任务标题保留`
|
|
2864
|
+
})
|
|
2865
|
+
} catch (err) {
|
|
2866
|
+
res.status(500).json({ success: false, error: '清空任务内容失败: ' + (err.message || String(err)) })
|
|
2867
|
+
}
|
|
2868
|
+
})
|
|
2869
|
+
|
|
2675
2870
|
// GET /api/workbench/jobs/:id
|
|
2676
2871
|
app.get('/api/workbench/jobs/:id', async (req, res) => {
|
|
2677
2872
|
try {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shell / PowerShell 字符串转义工具
|
|
18
|
+
*
|
|
19
|
+
* 提供三种转义策略:
|
|
20
|
+
* - shQuote: POSIX shell 单引号包字符串(git add / osascript / gnome-terminal 用)
|
|
21
|
+
* - psEscape: PowerShell 双引号内安全转义(terminal.js 的 PowerShell 脚本用)
|
|
22
|
+
* - appleEscape: AppleScript 双引号字符串内安全转义(macOS 终端用)
|
|
23
|
+
*
|
|
24
|
+
* 三种都不依赖外部包,纯字符串操作。
|
|
25
|
+
*
|
|
26
|
+
* @typedef {string | number | null | undefined} ShellInput
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* POSIX shell 单引号包裹
|
|
31
|
+
* 单引号内不解释 $ ` " \,只把单引号自身转义为 '\''
|
|
32
|
+
* 例: shQuote('a"b$c') === "'a\"b$c'"
|
|
33
|
+
*
|
|
34
|
+
* @param {ShellInput} input
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function shQuote(input) {
|
|
38
|
+
if (input === null || input === undefined) return "''"
|
|
39
|
+
return `'${String(input).replace(/'/g, `'\\''`)}'`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* PowerShell 双引号字符串内安全转义
|
|
44
|
+
* 需要把 $ (变量/子表达式) 和 ` (子表达式) 各自前置一个反引号
|
|
45
|
+
* 双引号本身用 "" 转义;反引号自身先用 `` 转义避免吃掉后续转义
|
|
46
|
+
*
|
|
47
|
+
* @param {ShellInput} input
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function psEscape(input) {
|
|
51
|
+
if (input === null || input === undefined) return '""'
|
|
52
|
+
return String(input)
|
|
53
|
+
.replace(/`/g, '``')
|
|
54
|
+
.replace(/\$/g, '`$')
|
|
55
|
+
.replace(/"/g, '""')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* AppleScript 双引号字符串内安全转义
|
|
60
|
+
* 反斜杠和双引号都需要前置反斜杠
|
|
61
|
+
*
|
|
62
|
+
* @param {ShellInput} input
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function appleEscape(input) {
|
|
66
|
+
if (input === null || input === undefined) return '""'
|
|
67
|
+
return String(input).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
68
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Copyright 2026 xz333221
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
//
|
|
15
|
+
// shellQuote 单元测试(node:test 内置)
|
|
16
|
+
import { test } from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { shQuote, psEscape, appleEscape } from '../utils/shellQuote.js'
|
|
19
|
+
|
|
20
|
+
test('shQuote 普通字符串', () => {
|
|
21
|
+
assert.equal(shQuote('hello'), "'hello'")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('shQuote 单引号需要转义', () => {
|
|
25
|
+
// POSIX shell: 单引号字符串里唯一需要处理的就是单引号自身
|
|
26
|
+
// 关闭单引号 + 转义 + 开启单引号 = '\''
|
|
27
|
+
assert.equal(shQuote("a'b"), "'a'\\''b'")
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('shQuote 注入字符也能合法包裹', () => {
|
|
31
|
+
// POSIX shell 单引号内 $ ` " \ 都不展开,所以原样包住就是安全的
|
|
32
|
+
// 函数职责只保证产出一个语法合法的单引号字符串
|
|
33
|
+
const evil = '$(rm -rf /); `whoami` "$PATH" \\path'
|
|
34
|
+
const quoted = shQuote(evil)
|
|
35
|
+
// 必须以单引号开头和结尾,内部任何单引号都被 '\'' 转义
|
|
36
|
+
assert.ok(quoted.startsWith("'"))
|
|
37
|
+
assert.ok(quoted.endsWith("'"))
|
|
38
|
+
// 内部裸的单引号不会破坏字符串:
|
|
39
|
+
// 统计未配对的单引号数量,被正确转义的 ' 必须成对出现 '\''
|
|
40
|
+
// 这里因为输入不含单引号,所以 quoted 形如 '<evil>'
|
|
41
|
+
assert.equal(quoted.length, evil.length + 2)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('shQuote null / undefined / number 都安全', () => {
|
|
45
|
+
assert.equal(shQuote(null), "''")
|
|
46
|
+
assert.equal(shQuote(undefined), "''")
|
|
47
|
+
assert.equal(shQuote(42), "'42'")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('shQuote 文件名场景:含空格和双引号', () => {
|
|
51
|
+
// 模拟 git status --porcelain 解出的文件名
|
|
52
|
+
assert.equal(shQuote('my file.txt'), "'my file.txt'")
|
|
53
|
+
assert.equal(shQuote('a"b.txt'), `'a"b.txt'`)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('psEscape $ 变量必须前置反引号', () => {
|
|
57
|
+
// 原: $(rm -rf /) → 期望: `$(rm -rf /)
|
|
58
|
+
// (PowerShell 双引号内 $ 会触发子表达式求值,前置 ` 取消求值)
|
|
59
|
+
assert.equal(psEscape('$(rm -rf /)'), '`$(rm -rf /)')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('psEscape 反引号自身先转义', () => {
|
|
63
|
+
// 反引号是 PowerShell 转义符,先自身转义避免吃掉后续的转义符
|
|
64
|
+
// 原: `n → 期望: ``n
|
|
65
|
+
assert.equal(psEscape('`n'), '``n')
|
|
66
|
+
// 原: $`n (3 字符) → 期望: `$``n (5 字符: ` $ ` ` n)
|
|
67
|
+
// 反引号先转: $``n (4) → 然后 $ 前置: `$``n (5)
|
|
68
|
+
assert.equal(psEscape('$`n'), '`$``n')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('psEscape 双引号用 "" 转义', () => {
|
|
72
|
+
assert.equal(psEscape('a"b'), 'a""b')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('psEscape 普通字符串不变', () => {
|
|
76
|
+
assert.equal(psEscape('hello world'), 'hello world')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('psEscape null / undefined → 空字符串', () => {
|
|
80
|
+
assert.equal(psEscape(null), '""')
|
|
81
|
+
assert.equal(psEscape(undefined), '""')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('psEscape 工作目录路径 (Windows 含反斜杠和 $)', () => {
|
|
85
|
+
// PowerShell 里 $env:TEMP 是变量; $ 开头的路径是注入面
|
|
86
|
+
const path = '$env:TEMP\\evil$(rm).txt'
|
|
87
|
+
const escaped = psEscape(path)
|
|
88
|
+
// 所有 $ 必须前置反引号
|
|
89
|
+
assert.ok(!escaped.match(/(?<!`)\$/), '不应有未转义的 $')
|
|
90
|
+
assert.ok(escaped.includes('`$env:'))
|
|
91
|
+
assert.ok(escaped.includes('`$(rm)'))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('appleEscape 双引号前置反斜杠', () => {
|
|
95
|
+
assert.equal(appleEscape('a"b'), 'a\\"b')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('appleEscape 反斜杠先转义', () => {
|
|
99
|
+
// 反斜杠要双写避免吃掉后面的 "
|
|
100
|
+
assert.equal(appleEscape('a\\"b'), 'a\\\\\\"b')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('appleEscape 普通字符串不变', () => {
|
|
104
|
+
assert.equal(appleEscape('cd /tmp && ls'), 'cd /tmp && ls')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('appleEscape null / undefined → 空字符串', () => {
|
|
108
|
+
assert.equal(appleEscape(null), '""')
|
|
109
|
+
assert.equal(appleEscape(undefined), '""')
|
|
110
|
+
})
|