@ghyper9023/pi-dev-workflow 0.3.3 → 0.4.1
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/.pi-dev-output/pi-grill/answers/answer-mpds3by7-20260520-1606.md +14 -0
- package/.pi-dev-output/pi-plans/20260520-153000-fix-workflow-engine-bugs.md +150 -0
- package/.pi-dev-output/pi-workflow/checkpoint-20260520-153000-fix-workflow-engine-bugs.json +108 -0
- package/README.md +171 -29
- package/agents/review-agent.md +5 -5
- package/agents/workflow/docWriter-agent.md +29 -0
- package/agents/workflow/planner-agent.md +80 -0
- package/agents/workflow/reviewer-agent.md +44 -0
- package/agents/workflow/trimmer-agent.md +34 -0
- package/agents/workflow/worker-agent.md +29 -0
- package/extensions/dev-prompts.ts +375 -75
- package/extensions/git-commands.ts +3 -13
- package/extensions/grill-me-agent.ts +138 -66
- package/extensions/sub-agents.ts +32 -11
- package/extensions/ui-helpers.ts +1029 -0
- package/extensions/workflow-engine.ts +1748 -0
- package/package.json +1 -1
- package/skills/review-html/SKILL.md +2 -2
- package/skills/to-prd/SKILL.md +1 -1
- package/tests/test-grill-json-fix.mjs +243 -0
- package/tests/test-output-directory-structure.mjs +177 -0
- package/tests/test-save-answer-file-workflow.mjs +187 -0
- package/tests/test-workflow-config.mjs +244 -0
- package/tests/test-workflow-engine-bugs.mjs +349 -0
- package/tests/test-workflow-engine.mjs +518 -0
|
@@ -0,0 +1,1748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* workflow-engine.ts — 工作流编排引擎
|
|
3
|
+
*
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. runWorkflow() — 主入口,编排多步骤工作流(后台异步执行)
|
|
6
|
+
* 2. 支持 值守/全自动/完全值守 三种模式
|
|
7
|
+
* 3. 支持 {} loop 组(worker→reviewer, trimmer→reviewer)
|
|
8
|
+
* 4. 支持 [] 标记的确认步骤
|
|
9
|
+
* 5. Checkpoint 保存/恢复(断点续传)
|
|
10
|
+
* 6. 超时处理(按 mode 策略分支)
|
|
11
|
+
* 7. 进度面板 UI — 使用 ctx.ui.setWidget() 持久化面板,支持 Ctrl+O 展开
|
|
12
|
+
*
|
|
13
|
+
* 被 dev-prompts.ts 引入,不独立作为 extension 加载。
|
|
14
|
+
*
|
|
15
|
+
* 设计要点:
|
|
16
|
+
* - 非阻塞执行:通过 AbortController 管理取消,widget 动画更新进度
|
|
17
|
+
* - 步骤详情:记录 agent 的工具调用、输出路径等子步骤信息
|
|
18
|
+
* - 归档:工作流完成后 checkpoint 重命名而非删除
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { Key, matchesKey } from "@earendil-works/pi-tui";
|
|
25
|
+
import { execSync } from "child_process";
|
|
26
|
+
import { spawnSubagent, extractFinalOutput, discoverAgents, type AgentDef, type SubagentResult } from "./sub-agents";
|
|
27
|
+
import {
|
|
28
|
+
uiSelect,
|
|
29
|
+
uiConfirm,
|
|
30
|
+
uiInput,
|
|
31
|
+
updateWorkflowWidget,
|
|
32
|
+
buildWidgetState,
|
|
33
|
+
sendWorkflowResult,
|
|
34
|
+
setWorkflowCancelCallback,
|
|
35
|
+
cancelWorkflow,
|
|
36
|
+
BACK_MARKER,
|
|
37
|
+
BACK_OPTION_TEXT,
|
|
38
|
+
type WorkflowStepWidgetState,
|
|
39
|
+
type WorkflowSubStepWidgetState,
|
|
40
|
+
type WorkflowWidgetState,
|
|
41
|
+
} from "./ui-helpers";
|
|
42
|
+
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════
|
|
44
|
+
// Types
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════
|
|
46
|
+
|
|
47
|
+
export type WorkflowMode = "attended" | "full-auto" | "full-attended";
|
|
48
|
+
|
|
49
|
+
export interface WorkflowStepDef {
|
|
50
|
+
id: string;
|
|
51
|
+
label: string;
|
|
52
|
+
type: "auto" | "confirm" | "loop-group";
|
|
53
|
+
agentName?: string;
|
|
54
|
+
loopAgentName?: string;
|
|
55
|
+
reviewAgentName?: string;
|
|
56
|
+
maxLoops?: number;
|
|
57
|
+
timeoutMs: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface WorkflowStepState {
|
|
61
|
+
status: "pending" | "running" | "done" | "failed" | "skipped";
|
|
62
|
+
durationMs?: number;
|
|
63
|
+
loopCount?: number;
|
|
64
|
+
error?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface FileChangeEntry {
|
|
68
|
+
agent: string;
|
|
69
|
+
stepIndex: number;
|
|
70
|
+
type: "edit" | "new" | "delete" | "read";
|
|
71
|
+
filePath: string;
|
|
72
|
+
timestamp: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface AgentRunEntry {
|
|
76
|
+
agent: string;
|
|
77
|
+
stepIndex: number;
|
|
78
|
+
startedAt: string;
|
|
79
|
+
durationMs: number;
|
|
80
|
+
exitCode: number;
|
|
81
|
+
toolCount: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Baseline entry: records the git object hash of a file at workflow start.
|
|
86
|
+
* Used to distinguish pre-existing dirty files from workflow-generated changes.
|
|
87
|
+
*/
|
|
88
|
+
interface BaselineEntry {
|
|
89
|
+
path: string;
|
|
90
|
+
hash: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface CheckpointData {
|
|
94
|
+
version: 2;
|
|
95
|
+
createdAt: string;
|
|
96
|
+
updatedAt: string;
|
|
97
|
+
prompt: string;
|
|
98
|
+
mode: WorkflowMode;
|
|
99
|
+
steps: WorkflowStepState[];
|
|
100
|
+
currentStepIndex: number;
|
|
101
|
+
loopCounts: Record<string, number>;
|
|
102
|
+
planFilePath?: string;
|
|
103
|
+
// New fields for better UI and traceability
|
|
104
|
+
taskSummary?: string;
|
|
105
|
+
workflowType?: string;
|
|
106
|
+
fileChanges?: FileChangeEntry[];
|
|
107
|
+
subAgentRuns?: number;
|
|
108
|
+
filesModified?: number;
|
|
109
|
+
filesCreated?: number;
|
|
110
|
+
agentRunHistory?: AgentRunEntry[];
|
|
111
|
+
/** Baseline snapshot taken at workflow start for accurate change tracking. */
|
|
112
|
+
baseline?: BaselineEntry[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ═══════════════════════════════════════════════════════════════
|
|
116
|
+
// Constants
|
|
117
|
+
// ═══════════════════════════════════════════════════════════════
|
|
118
|
+
|
|
119
|
+
const DEV_OUTPUT_DIR = ".pi-dev-output";
|
|
120
|
+
const CHECKPOINT_FILE = path.join(DEV_OUTPUT_DIR, "pi-workflow", "checkpoint.json");
|
|
121
|
+
const PLANS_DIR = path.join(DEV_OUTPUT_DIR, "pi-plans");
|
|
122
|
+
|
|
123
|
+
// ═══════════════════════════════════════════════════════════════
|
|
124
|
+
// Utility Helpers
|
|
125
|
+
// ═══════════════════════════════════════════════════════════════
|
|
126
|
+
|
|
127
|
+
function ensureOutputDir(cwd: string, subdir: string): string {
|
|
128
|
+
const dir = path.join(cwd, DEV_OUTPUT_DIR, subdir);
|
|
129
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
130
|
+
return dir;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findLatestPlanFile(cwd: string): string | undefined {
|
|
134
|
+
const dir = path.join(cwd, PLANS_DIR);
|
|
135
|
+
try {
|
|
136
|
+
if (!fs.existsSync(dir)) return undefined;
|
|
137
|
+
const files = fs.readdirSync(dir)
|
|
138
|
+
.filter(f => f.endsWith(".md"))
|
|
139
|
+
.map(f => ({ name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs }))
|
|
140
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
141
|
+
return files.length > 0 ? path.join(PLANS_DIR, files[0].name) : undefined;
|
|
142
|
+
} catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readFileContent(cwd: string, relativePath: string): string | undefined {
|
|
148
|
+
try {
|
|
149
|
+
return fs.readFileSync(path.join(cwd, relativePath), "utf-8");
|
|
150
|
+
} catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function parseReviewerOutput(
|
|
156
|
+
output: string,
|
|
157
|
+
): { maxSeverity: string; critical: number; medium: number; low: number } | null {
|
|
158
|
+
const match = output.match(/\[REVIEW_SUMMARY\]\s*(\{[\s\S]*?\})\s*\[\/REVIEW_SUMMARY\]/);
|
|
159
|
+
if (match) {
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(match[1]);
|
|
162
|
+
if (parsed && typeof parsed.maxSeverity === "string") return parsed;
|
|
163
|
+
} catch { /* fallthrough */ }
|
|
164
|
+
}
|
|
165
|
+
const fallback = output.match(/\{"maxSeverity":\s*"(critical|medium|low)"[\s\S]*?\}/);
|
|
166
|
+
if (fallback) {
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(fallback[0]);
|
|
169
|
+
if (parsed && typeof parsed.maxSeverity === "string") return parsed;
|
|
170
|
+
} catch { /* fallthrough */ }
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function extractSeverityFromText(
|
|
176
|
+
text: string,
|
|
177
|
+
): { maxSeverity: string; critical: number; medium: number; low: number } | null {
|
|
178
|
+
const headerCritical = [...text.matchAll(/^###\s+C\d+\./gm)].length;
|
|
179
|
+
const headerMedium = [...text.matchAll(/^###\s+M\d+\./gm)].length;
|
|
180
|
+
const headerLow = [...text.matchAll(/^###\s+L\d+\./gm)].length;
|
|
181
|
+
if (headerCritical + headerMedium + headerLow > 0) {
|
|
182
|
+
return {
|
|
183
|
+
maxSeverity: headerCritical > 0 ? "critical" : headerMedium > 0 ? "medium" : "low",
|
|
184
|
+
critical: headerCritical,
|
|
185
|
+
medium: headerMedium,
|
|
186
|
+
low: headerLow,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const tableCritical = [...text.matchAll(/^\|\s*\w+\s*\|\s*critical/gim)].length;
|
|
190
|
+
const tableMedium = [...text.matchAll(/^\|\s*\w+\s*\|\s*medium/gim)].length;
|
|
191
|
+
const tableLow = [...text.matchAll(/^\|\s*\w+\s*\|\s*low/gim)].length;
|
|
192
|
+
if (tableCritical + tableMedium + tableLow > 0) {
|
|
193
|
+
return {
|
|
194
|
+
maxSeverity: tableCritical > 0 ? "critical" : tableMedium > 0 ? "medium" : "low",
|
|
195
|
+
critical: tableCritical,
|
|
196
|
+
medium: tableMedium,
|
|
197
|
+
low: tableLow,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const labelCritical = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*critical/gi)].length;
|
|
201
|
+
const labelMedium = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*medium/gi)].length;
|
|
202
|
+
const labelLow = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*low/gi)].length;
|
|
203
|
+
if (labelCritical + labelMedium + labelLow > 0) {
|
|
204
|
+
return {
|
|
205
|
+
maxSeverity: labelCritical > 0 ? "critical" : labelMedium > 0 ? "medium" : "low",
|
|
206
|
+
critical: labelCritical,
|
|
207
|
+
medium: labelMedium,
|
|
208
|
+
low: labelLow,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function readLatestReviewMd(cwd: string): string | null {
|
|
215
|
+
const reviewDir = path.join(cwd, DEV_OUTPUT_DIR, "pi-review", "md");
|
|
216
|
+
try {
|
|
217
|
+
if (!fs.existsSync(reviewDir)) return null;
|
|
218
|
+
const files = fs.readdirSync(reviewDir)
|
|
219
|
+
.filter(f => f.endsWith(".md"))
|
|
220
|
+
.map(f => ({ name: f, mtime: fs.statSync(path.join(reviewDir, f)).mtimeMs }))
|
|
221
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
222
|
+
if (files.length === 0) return null;
|
|
223
|
+
return fs.readFileSync(path.join(reviewDir, files[0].name), "utf-8");
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function isTimeoutResult(r: SubagentResult): boolean {
|
|
230
|
+
return r.exitCode === -1 && r.stderr.includes("timed out");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Extract a human-readable task summary from the prompt.
|
|
235
|
+
*/
|
|
236
|
+
export function extractTaskSummary(prompt: string): string {
|
|
237
|
+
const firstLine = prompt.split("\n").find(l => l.trim()) ?? "";
|
|
238
|
+
const tagMatch = firstLine.match(/^\[([^\]]+)\]\s*(.+)/);
|
|
239
|
+
if (tagMatch) {
|
|
240
|
+
const tag = tagMatch[1]!.trim();
|
|
241
|
+
const rest = tagMatch[2]!.trim();
|
|
242
|
+
// If the rest looks like placeholder dots, try to find a better summary
|
|
243
|
+
if (rest.replace(/\.\.\./g, "").trim() === "" || rest === "...") {
|
|
244
|
+
const lines = prompt.split("\n").filter(l => l.trim());
|
|
245
|
+
if (lines.length > 1) {
|
|
246
|
+
const secondLine = lines[1]!.replace(/^[*\s#]+/, "").trim();
|
|
247
|
+
if (secondLine && !secondLine.startsWith("**")) {
|
|
248
|
+
return `${tag} - ${secondLine.substring(0, 60)}`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const line of lines.slice(1, 5)) {
|
|
252
|
+
const cleaned = line.replace(/^[*\s#]+/, "").trim();
|
|
253
|
+
if (cleaned && cleaned.length > 5 && !cleaned.startsWith("**") && !cleaned.startsWith("`")) {
|
|
254
|
+
const summary = cleaned.length > 50 ? cleaned.substring(0, 47) + "..." : cleaned;
|
|
255
|
+
return `${tag} - ${summary}`;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return `${tag} - 工作流任务`;
|
|
259
|
+
}
|
|
260
|
+
return `${tag} - ${rest}`;
|
|
261
|
+
}
|
|
262
|
+
const cleaned = firstLine.replace(/^[*\s#]+/, "").trim();
|
|
263
|
+
return cleaned.length > 60 ? cleaned.substring(0, 57) + "..." : cleaned || "工作流任务";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
// ── Git diff-based file change detection ──────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* TypeScript struct for a parsed git file change entry.
|
|
271
|
+
* `status` uses git's canonical single-letter codes: M (modify), A (add), D (delete).
|
|
272
|
+
*/
|
|
273
|
+
interface GitFileChange {
|
|
274
|
+
status: "M" | "A" | "D";
|
|
275
|
+
path: string;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Run `git diff --name-status` to detect file changes against HEAD.
|
|
280
|
+
* This is far more reliable than scraping AI text output for file paths.
|
|
281
|
+
*
|
|
282
|
+
* Uses `git diff --name-status HEAD` for modified/deleted files, and
|
|
283
|
+
* `git status --porcelain` to catch untracked (new) files.
|
|
284
|
+
*
|
|
285
|
+
* Returns an array of { status, path } where status is "M"/"A"/"D" matching
|
|
286
|
+
* git's own format, ready for direct display in the widget.
|
|
287
|
+
*/
|
|
288
|
+
function getGitDiffChanges(cwd: string): GitFileChange[] {
|
|
289
|
+
const changes: GitFileChange[] = [];
|
|
290
|
+
const seen = new Set<string>();
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
// 1. `git diff --name-status` — shows modified (M) and deleted (D) vs HEAD
|
|
294
|
+
const diffOutput = execSync("git diff --name-status", { cwd, encoding: "utf8", timeout: 5000 }).trim();
|
|
295
|
+
if (diffOutput) {
|
|
296
|
+
for (const line of diffOutput.split("\n")) {
|
|
297
|
+
const trimmed = line.trim();
|
|
298
|
+
if (!trimmed) continue;
|
|
299
|
+
// Format: "M\tpath/to/file" or "D\tpath/to/file"
|
|
300
|
+
const match = trimmed.match(/^([MAD])\s+(.+)$/);
|
|
301
|
+
if (match) {
|
|
302
|
+
const status = match[1]! as "M" | "A" | "D";
|
|
303
|
+
const path = match[2]!.trim();
|
|
304
|
+
if (path && !seen.has(path)) {
|
|
305
|
+
seen.add(path);
|
|
306
|
+
changes.push({ status, path });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 2. `git status --porcelain` — find untracked files (??) missing from git diff
|
|
313
|
+
const statusOutput = execSync("git status --porcelain", { cwd, encoding: "utf8", timeout: 5000 }).trim();
|
|
314
|
+
if (statusOutput) {
|
|
315
|
+
for (const line of statusOutput.split("\n")) {
|
|
316
|
+
const trimmed = line.trim();
|
|
317
|
+
if (!trimmed) continue;
|
|
318
|
+
// "?? path" means untracked/new
|
|
319
|
+
// Also catch "A path" for staged new files
|
|
320
|
+
const match = trimmed.match(/^(\?\?|A\s)\s+(.+)$/);
|
|
321
|
+
if (match) {
|
|
322
|
+
const path = match[2]!.trim();
|
|
323
|
+
if (path && !seen.has(path)) {
|
|
324
|
+
seen.add(path);
|
|
325
|
+
changes.push({ status: "A", path });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
// Git not available or not a repo — silently skip
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return changes;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Convert an internal tool type ("edit"/"new"/"delete") to a git status letter ("M"/"A"/"D").
|
|
339
|
+
* Used to unify all tool entries to git-format display: "M path", "A path", "D path".
|
|
340
|
+
*/
|
|
341
|
+
function toGitStatus(toolType: string): string {
|
|
342
|
+
switch (toolType.toLowerCase()) {
|
|
343
|
+
case "new": case "write": case "create": case "add": case "created": case "added":
|
|
344
|
+
return "A";
|
|
345
|
+
case "delete": case "remove": case "deleted": case "removed":
|
|
346
|
+
return "D";
|
|
347
|
+
default:
|
|
348
|
+
return "M";
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Check whether a file's current content hash differs from its baseline.
|
|
354
|
+
* Returns true if the hash changed or the file is no longer accessible.
|
|
355
|
+
*/
|
|
356
|
+
function hasContentChanged(cwd: string, path: string, baselineHash: string): boolean {
|
|
357
|
+
try {
|
|
358
|
+
const currentHash = require('child_process').spawnSync('git', ['hash-object', path], { cwd, encoding: 'utf8', timeout: 3000 }).stdout?.trim() || "";
|
|
359
|
+
return currentHash !== baselineHash;
|
|
360
|
+
} catch {
|
|
361
|
+
// file deleted or inaccessible — consider changed
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Capture baseline snapshot of all dirty files at workflow start.
|
|
368
|
+
* Records git object hashes so we can later distinguish pre-existing
|
|
369
|
+
* changes from workflow-generated ones.
|
|
370
|
+
*/
|
|
371
|
+
function captureBaseline(cwd: string): void {
|
|
372
|
+
_workflowBaseline = [];
|
|
373
|
+
try {
|
|
374
|
+
const changes = getGitDiffChanges(cwd);
|
|
375
|
+
for (const change of changes) {
|
|
376
|
+
let hash = "";
|
|
377
|
+
try {
|
|
378
|
+
hash = execSync(`git hash-object "${change.path}"`, { cwd, encoding: "utf8", timeout: 3000 }).trim();
|
|
379
|
+
} catch {
|
|
380
|
+
// file might not exist (deleted in diff)
|
|
381
|
+
}
|
|
382
|
+
_workflowBaseline.push({ path: change.path, hash });
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
// git not available or not a repo
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Update widget tool list from git diff changes, filtered against the baseline.
|
|
391
|
+
* Only reports changes that are genuinely new or modified BY the workflow,
|
|
392
|
+
* not pre-existing dirty files that the workflow didn't touch.
|
|
393
|
+
*
|
|
394
|
+
* Uses git-format status codes (M, A, D) for display consistency.
|
|
395
|
+
* Deduplicates against existing _workflowFileChanges.
|
|
396
|
+
*/
|
|
397
|
+
function updateToolsFromGit(cwd: string, stepIndex: number, agentName: string): void {
|
|
398
|
+
const currentChanges = getGitDiffChanges(cwd);
|
|
399
|
+
const seen = new Set(_workflowFileChanges.map(c => c.filePath));
|
|
400
|
+
|
|
401
|
+
for (const change of currentChanges) {
|
|
402
|
+
if (seen.has(change.path)) continue;
|
|
403
|
+
|
|
404
|
+
// ── Baseline filtering ──────────────────────────────
|
|
405
|
+
// Skip files that were already dirty at workflow start and haven't been touched.
|
|
406
|
+
if (_workflowBaseline.length > 0) {
|
|
407
|
+
const baselineEntry = _workflowBaseline.find(b => b.path === change.path);
|
|
408
|
+
if (baselineEntry) {
|
|
409
|
+
if (!hasContentChanged(cwd, change.path, baselineEntry.hash)) {
|
|
410
|
+
// Pre-existing dirty file, content unchanged — workflow didn't touch it
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// Content changed → workflow modified it, report as new change
|
|
414
|
+
// (Remove old baseline entry so next git diff sees it as workflow-owned)
|
|
415
|
+
_workflowBaseline = _workflowBaseline.filter(b => b.path !== change.path);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
seen.add(change.path);
|
|
420
|
+
const type: FileChangeEntry["type"] =
|
|
421
|
+
change.status === "A" ? "new" :
|
|
422
|
+
change.status === "D" ? "delete" :
|
|
423
|
+
"edit";
|
|
424
|
+
_workflowFileChanges.push({
|
|
425
|
+
agent: agentName,
|
|
426
|
+
stepIndex,
|
|
427
|
+
type,
|
|
428
|
+
filePath: change.path,
|
|
429
|
+
timestamp: new Date().toISOString(),
|
|
430
|
+
});
|
|
431
|
+
// Use git-style format for display: "M path", "A path", "D path"
|
|
432
|
+
addWidgetSubStepTool(stepIndex, agentName, `${change.status} ${change.path}`);
|
|
433
|
+
_widgetExtraToolCount++;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// ═══════════════════════════════════════════════════════════════
|
|
437
|
+
// Checkpoint
|
|
438
|
+
// ═══════════════════════════════════════════════════════════════
|
|
439
|
+
|
|
440
|
+
function saveCheckpoint(cwd: string, data: CheckpointData): void {
|
|
441
|
+
ensureOutputDir(cwd, "pi-workflow");
|
|
442
|
+
data.updatedAt = new Date().toISOString();
|
|
443
|
+
// Always version 2
|
|
444
|
+
(data as CheckpointData).version = 2;
|
|
445
|
+
// Enrich with file changes and agent history from module state
|
|
446
|
+
if (_workflowFileChanges.length > 0 && !data.fileChanges) {
|
|
447
|
+
data.fileChanges = [..._workflowFileChanges];
|
|
448
|
+
}
|
|
449
|
+
if (_workflowAgentRunHistory.length > 0 && !data.agentRunHistory) {
|
|
450
|
+
data.agentRunHistory = [..._workflowAgentRunHistory];
|
|
451
|
+
}
|
|
452
|
+
fs.writeFileSync(path.join(cwd, CHECKPOINT_FILE), JSON.stringify(data, null, 2), "utf-8");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function loadCheckpointFromFile(cwd: string): CheckpointData | null {
|
|
456
|
+
try {
|
|
457
|
+
const content = fs.readFileSync(path.join(cwd, CHECKPOINT_FILE), "utf-8");
|
|
458
|
+
const data = JSON.parse(content) as CheckpointData;
|
|
459
|
+
// Backfill missing fields for v1 checkpoints
|
|
460
|
+
if (!data.version || data.version < 2) {
|
|
461
|
+
data.version = 2;
|
|
462
|
+
data.fileChanges = data.fileChanges ?? [];
|
|
463
|
+
data.agentRunHistory = data.agentRunHistory ?? [];
|
|
464
|
+
}
|
|
465
|
+
return data;
|
|
466
|
+
} catch {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Archive checkpoint after completion: rename to checkpoint-<plan-id>.json
|
|
473
|
+
*/
|
|
474
|
+
export function archiveCheckpointFile(cwd: string, planFileRelPath?: string): void {
|
|
475
|
+
try {
|
|
476
|
+
const cpPath = path.join(cwd, CHECKPOINT_FILE);
|
|
477
|
+
if (!fs.existsSync(cpPath)) return;
|
|
478
|
+
const planId = planFileRelPath
|
|
479
|
+
? path.basename(planFileRelPath, ".md").replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
480
|
+
: `archive-${Date.now().toString(36)}`;
|
|
481
|
+
const archiveName = `checkpoint-${planId}.json`;
|
|
482
|
+
const archiveDir = path.join(cwd, DEV_OUTPUT_DIR, "pi-workflow");
|
|
483
|
+
fs.renameSync(cpPath, path.join(archiveDir, archiveName));
|
|
484
|
+
} catch { /* ignore */ }
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ═══════════════════════════════════════════════════════════════
|
|
488
|
+
// Task builders
|
|
489
|
+
// ═══════════════════════════════════════════════════════════════
|
|
490
|
+
|
|
491
|
+
function buildTaskForStep(
|
|
492
|
+
agentName: string,
|
|
493
|
+
prompt: string,
|
|
494
|
+
planFileRelPath: string | undefined,
|
|
495
|
+
cwd: string,
|
|
496
|
+
): string {
|
|
497
|
+
if (agentName === "planner") {
|
|
498
|
+
return [
|
|
499
|
+
"请根据以下功能需求,分析代码库结构,生成详细的实施计划,并写入 .pi-dev-output/pi-plans/ 目录。",
|
|
500
|
+
"",
|
|
501
|
+
"## 功能需求",
|
|
502
|
+
prompt,
|
|
503
|
+
].join("\n");
|
|
504
|
+
}
|
|
505
|
+
if (agentName === "worker") {
|
|
506
|
+
const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined;
|
|
507
|
+
if (planContent) {
|
|
508
|
+
return [
|
|
509
|
+
"请根据以下实施计划逐步实现代码改动。",
|
|
510
|
+
"",
|
|
511
|
+
"## 实施计划",
|
|
512
|
+
planContent,
|
|
513
|
+
"",
|
|
514
|
+
"请严格按照计划中的步骤实施,不要做计划外的修改。",
|
|
515
|
+
].join("\n");
|
|
516
|
+
}
|
|
517
|
+
return [
|
|
518
|
+
"请根据以下功能需求实施代码改动。",
|
|
519
|
+
"",
|
|
520
|
+
"## 功能需求",
|
|
521
|
+
prompt,
|
|
522
|
+
"",
|
|
523
|
+
"请先分析代码库,制定简要计划,再逐步实施。",
|
|
524
|
+
].join("\n");
|
|
525
|
+
}
|
|
526
|
+
if (agentName === "trimmer") {
|
|
527
|
+
return [
|
|
528
|
+
"请精简当前代码库的代码。",
|
|
529
|
+
"缩短不必要的冗长行,优化可读性,消除可合并的重复逻辑。",
|
|
530
|
+
"",
|
|
531
|
+
"## 原始功能需求",
|
|
532
|
+
prompt,
|
|
533
|
+
].join("\n");
|
|
534
|
+
}
|
|
535
|
+
if (agentName === "docWriter") {
|
|
536
|
+
const planContent = planFileRelPath
|
|
537
|
+
? `\n\n## 实施计划\n${readFileContent(cwd, planFileRelPath) ?? ""}`
|
|
538
|
+
: "";
|
|
539
|
+
return [
|
|
540
|
+
"请根据当前代码状态,更新 README.md 文档,必要时添加关键代码注释。",
|
|
541
|
+
"",
|
|
542
|
+
"## 功能需求",
|
|
543
|
+
prompt,
|
|
544
|
+
planContent,
|
|
545
|
+
].join("\n");
|
|
546
|
+
}
|
|
547
|
+
return prompt;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function buildReviewTask(
|
|
551
|
+
prompt: string,
|
|
552
|
+
planFileRelPath: string | undefined,
|
|
553
|
+
cwd: string,
|
|
554
|
+
): string {
|
|
555
|
+
const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined;
|
|
556
|
+
const parts = [
|
|
557
|
+
"请审查当前代码库中针对以下功能的实现。",
|
|
558
|
+
"检查是否有 bug、逻辑错误、未完成的功能、代码质量问题。",
|
|
559
|
+
"将详细审查报告写入 .pi-dev-output/pi-review/md/ 目录。",
|
|
560
|
+
"在回复末尾输出以下格式的结构化摘要(必须包含):",
|
|
561
|
+
"[REVIEW_SUMMARY]",
|
|
562
|
+
'{"maxSeverity":"critical|medium|low","critical":N,"medium":N,"low":N}',
|
|
563
|
+
"[/REVIEW_SUMMARY]",
|
|
564
|
+
"",
|
|
565
|
+
"## 功能需求",
|
|
566
|
+
prompt,
|
|
567
|
+
];
|
|
568
|
+
if (planContent) parts.push("", "## 实施计划", planContent);
|
|
569
|
+
return parts.join("\n");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ═══════════════════════════════════════════════════════════════
|
|
573
|
+
// Global state for async execution
|
|
574
|
+
// ═══════════════════════════════════════════════════════════════
|
|
575
|
+
|
|
576
|
+
interface StepRuntimeInfo {
|
|
577
|
+
widgetStep: WorkflowStepWidgetState;
|
|
578
|
+
state: WorkflowStepState;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
let _workflowAbortController: AbortController | null = null;
|
|
582
|
+
let _workflowPi: ExtensionAPI | null = null;
|
|
583
|
+
let _workflowType: string | undefined;
|
|
584
|
+
let _workflowCwd = "";
|
|
585
|
+
let _workflowPrompt = "";
|
|
586
|
+
let _workflowPlanFileRelPath: string | undefined;
|
|
587
|
+
/** Track loop counts at module level so cancel callback can save a proper checkpoint. */
|
|
588
|
+
let _workflowLoopCounts: Record<string, number> = {};
|
|
589
|
+
/** Original checkpoint creation timestamp, for preserving across cancel. */
|
|
590
|
+
let _workflowCreatedAt: string = new Date().toISOString();
|
|
591
|
+
/** Track file changes globally for checkpoint persistence */
|
|
592
|
+
let _workflowFileChanges: FileChangeEntry[] = [];
|
|
593
|
+
/** Track agent run history */
|
|
594
|
+
let _workflowAgentRunHistory: AgentRunEntry[] = [];
|
|
595
|
+
/** Store step defs for pre-populating sub-steps */
|
|
596
|
+
let _workflowStepDefs: WorkflowStepDef[] = [];
|
|
597
|
+
/** Baseline snapshot: git hashes of dirty files at workflow start */
|
|
598
|
+
let _workflowBaseline: BaselineEntry[] = [];
|
|
599
|
+
|
|
600
|
+
let _widgetMode: WorkflowMode = "attended";
|
|
601
|
+
let _widgetSteps: WorkflowStepWidgetState[] = [];
|
|
602
|
+
let _widgetCurrentIdx = 0;
|
|
603
|
+
let _widgetStartTime = 0;
|
|
604
|
+
let _widgetExtraToolCount = 0;
|
|
605
|
+
let _widgetExtraTokenCount = 0;
|
|
606
|
+
let _workflowRunning = false;
|
|
607
|
+
let _cleanupTimer: ReturnType<typeof setTimeout> | null = null;
|
|
608
|
+
|
|
609
|
+
function refreshWidget(): void {
|
|
610
|
+
if (!_lastWorkflowCtx) return;
|
|
611
|
+
const taskSummary = extractTaskSummary(_workflowPrompt);
|
|
612
|
+
const widgetState = buildWidgetState(
|
|
613
|
+
_widgetMode,
|
|
614
|
+
_widgetSteps,
|
|
615
|
+
_widgetCurrentIdx,
|
|
616
|
+
_widgetStartTime,
|
|
617
|
+
_workflowRunning ? "running" :
|
|
618
|
+
_widgetSteps.some(s => s.status === "failed") ? "failed" :
|
|
619
|
+
_widgetSteps.every(s => s.status === "done" || s.status === "skipped") ? "done" :
|
|
620
|
+
"running",
|
|
621
|
+
{ toolCount: _widgetExtraToolCount, tokenCount: _widgetExtraTokenCount },
|
|
622
|
+
taskSummary,
|
|
623
|
+
);
|
|
624
|
+
updateWorkflowWidget(_lastWorkflowCtx, widgetState);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let _lastWorkflowCtx: ExtensionCommandContext | null = null;
|
|
628
|
+
|
|
629
|
+
function initWidget(ctx: ExtensionCommandContext, mode: WorkflowMode, stepsCount: number): void {
|
|
630
|
+
_widgetMode = mode;
|
|
631
|
+
_widgetSteps = [];
|
|
632
|
+
for (let i = 0; i < stepsCount; i++) {
|
|
633
|
+
_widgetSteps.push({ label: "", status: "pending" });
|
|
634
|
+
}
|
|
635
|
+
_widgetCurrentIdx = 0;
|
|
636
|
+
_widgetStartTime = Date.now();
|
|
637
|
+
_widgetExtraToolCount = 0;
|
|
638
|
+
_widgetExtraTokenCount = 0;
|
|
639
|
+
if (_cleanupTimer) {
|
|
640
|
+
clearTimeout(_cleanupTimer);
|
|
641
|
+
_cleanupTimer = null;
|
|
642
|
+
}
|
|
643
|
+
_lastWorkflowCtx = ctx;
|
|
644
|
+
_workflowRunning = true;
|
|
645
|
+
refreshWidget();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function updateWidgetStep(
|
|
649
|
+
index: number,
|
|
650
|
+
label: string,
|
|
651
|
+
status: WorkflowStepWidgetState["status"],
|
|
652
|
+
extra?: {
|
|
653
|
+
durationMs?: number;
|
|
654
|
+
loopCount?: number;
|
|
655
|
+
maxLoops?: number;
|
|
656
|
+
timeoutMs?: number;
|
|
657
|
+
error?: string;
|
|
658
|
+
subSteps?: WorkflowSubStepWidgetState[];
|
|
659
|
+
startedAt?: number;
|
|
660
|
+
},
|
|
661
|
+
): void {
|
|
662
|
+
if (index < _widgetSteps.length) {
|
|
663
|
+
const existing = _widgetSteps[index];
|
|
664
|
+
_widgetSteps[index] = {
|
|
665
|
+
...existing,
|
|
666
|
+
label,
|
|
667
|
+
status,
|
|
668
|
+
...extra,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
refreshWidget();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function populatePredefinedSubSteps(stepIndex: number): void {
|
|
675
|
+
const step = _widgetSteps[stepIndex];
|
|
676
|
+
if (!step || !_workflowStepDefs[stepIndex]) return;
|
|
677
|
+
if (step.subSteps && step.subSteps.length > 0) return; // already populated
|
|
678
|
+
|
|
679
|
+
const def = _workflowStepDefs[stepIndex]!;
|
|
680
|
+
const newSubSteps: WorkflowSubStepWidgetState[] = [];
|
|
681
|
+
|
|
682
|
+
if (def.type === "loop-group") {
|
|
683
|
+
if (def.loopAgentName) {
|
|
684
|
+
newSubSteps.push({
|
|
685
|
+
agent: def.loopAgentName,
|
|
686
|
+
status: "pending",
|
|
687
|
+
tools: [],
|
|
688
|
+
outputs: [],
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
if (def.reviewAgentName) {
|
|
692
|
+
newSubSteps.push({
|
|
693
|
+
agent: def.reviewAgentName,
|
|
694
|
+
status: "pending",
|
|
695
|
+
tools: [],
|
|
696
|
+
outputs: [],
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
} else if (def.agentName) {
|
|
700
|
+
newSubSteps.push({
|
|
701
|
+
agent: def.agentName,
|
|
702
|
+
status: "pending",
|
|
703
|
+
tools: [],
|
|
704
|
+
outputs: [],
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (newSubSteps.length > 0) {
|
|
709
|
+
step.subSteps = newSubSteps;
|
|
710
|
+
refreshWidget();
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function addWidgetSubStepTool(stepIndex: number, agentName: string, tool: string): void {
|
|
715
|
+
const step = _widgetSteps[stepIndex];
|
|
716
|
+
if (!step) return;
|
|
717
|
+
const sub = step.subSteps?.find(s => s.agent === agentName);
|
|
718
|
+
if (sub) {
|
|
719
|
+
if (!sub.tools) sub.tools = [];
|
|
720
|
+
sub.tools.push(tool);
|
|
721
|
+
if (sub.tools.length > 20) sub.tools = sub.tools.slice(-20); // keep last 20
|
|
722
|
+
|
|
723
|
+
// Also track as file change for checkpoint
|
|
724
|
+
// Support both old format ("edit: path") and new git-format ("M path", "A path", "D path")
|
|
725
|
+
const oldMatch = tool.match(/^(edit|new|delete|read):\s*(.+)/i);
|
|
726
|
+
const gitMatch = !oldMatch ? tool.match(/^([MAD])\s{2,}(.+)$/) : null;
|
|
727
|
+
if (oldMatch) {
|
|
728
|
+
const changeType = oldMatch[1]!.toLowerCase() as FileChangeEntry["type"];
|
|
729
|
+
const filePath = oldMatch[2]!.trim();
|
|
730
|
+
const exists = _workflowFileChanges.some(
|
|
731
|
+
c => c.filePath === filePath && c.type === changeType && c.stepIndex === stepIndex && c.agent === agentName,
|
|
732
|
+
);
|
|
733
|
+
if (!exists && filePath.length > 3) {
|
|
734
|
+
_workflowFileChanges.push({
|
|
735
|
+
agent: agentName,
|
|
736
|
+
stepIndex,
|
|
737
|
+
type: changeType,
|
|
738
|
+
filePath,
|
|
739
|
+
timestamp: new Date().toISOString(),
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
} else if (gitMatch) {
|
|
743
|
+
const gitStatus = gitMatch[1]!;
|
|
744
|
+
const changeType: FileChangeEntry["type"] =
|
|
745
|
+
gitStatus === "A" ? "new" :
|
|
746
|
+
gitStatus === "D" ? "delete" :
|
|
747
|
+
"edit";
|
|
748
|
+
const filePath = gitMatch[2]!.trim();
|
|
749
|
+
const exists = _workflowFileChanges.some(
|
|
750
|
+
c => c.filePath === filePath && c.type === changeType && c.stepIndex === stepIndex && c.agent === agentName,
|
|
751
|
+
);
|
|
752
|
+
if (!exists && filePath.length > 3) {
|
|
753
|
+
_workflowFileChanges.push({
|
|
754
|
+
agent: agentName,
|
|
755
|
+
stepIndex,
|
|
756
|
+
type: changeType,
|
|
757
|
+
filePath,
|
|
758
|
+
timestamp: new Date().toISOString(),
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
refreshWidget();
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function addWidgetSubStepOutput(stepIndex: number, agentName: string, output: string): void {
|
|
768
|
+
const step = _widgetSteps[stepIndex];
|
|
769
|
+
if (!step) return;
|
|
770
|
+
const sub = step.subSteps?.find(s => s.agent === agentName);
|
|
771
|
+
if (sub) {
|
|
772
|
+
if (!sub.outputs) sub.outputs = [];
|
|
773
|
+
if (!sub.outputs.includes(output)) {
|
|
774
|
+
sub.outputs.push(output);
|
|
775
|
+
}
|
|
776
|
+
refreshWidget();
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function setWidgetSubStepStatus(stepIndex: number, agentName: string, status: WorkflowSubStepWidgetState["status"]): void {
|
|
781
|
+
const step = _widgetSteps[stepIndex];
|
|
782
|
+
if (!step) return;
|
|
783
|
+
const sub = step.subSteps?.find(s => s.agent === agentName);
|
|
784
|
+
if (sub) {
|
|
785
|
+
sub.status = status;
|
|
786
|
+
refreshWidget();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function setWidgetCurrentStep(index: number): void {
|
|
791
|
+
_widgetCurrentIdx = index;
|
|
792
|
+
refreshWidget();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function cleanupWidget(): void {
|
|
796
|
+
if (_cleanupTimer) {
|
|
797
|
+
clearTimeout(_cleanupTimer);
|
|
798
|
+
_cleanupTimer = null;
|
|
799
|
+
}
|
|
800
|
+
_workflowRunning = false;
|
|
801
|
+
if (_lastWorkflowCtx) {
|
|
802
|
+
updateWorkflowWidget(_lastWorkflowCtx, null);
|
|
803
|
+
_lastWorkflowCtx = null;
|
|
804
|
+
}
|
|
805
|
+
_workflowAbortController = null;
|
|
806
|
+
setWorkflowCancelCallback(null);
|
|
807
|
+
// Clean up terminal input listener (Esc)
|
|
808
|
+
if (_terminalInputUnsubscribe) {
|
|
809
|
+
_terminalInputUnsubscribe();
|
|
810
|
+
_terminalInputUnsubscribe = null;
|
|
811
|
+
}
|
|
812
|
+
// Clean up signal handlers
|
|
813
|
+
cleanupSignalHandlers();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Unsubscribe function for terminal input listener (Esc to cancel) */
|
|
817
|
+
let _terminalInputUnsubscribe: (() => void) | null = null;
|
|
818
|
+
|
|
819
|
+
// ── Signal handling (SIGINT/SIGTERM) for graceful workflow cancellation ──
|
|
820
|
+
|
|
821
|
+
let _signalHandlersRegistered = false;
|
|
822
|
+
|
|
823
|
+
function cleanupSignalHandlers(): void {
|
|
824
|
+
if (!_signalHandlersRegistered) return;
|
|
825
|
+
try { process.removeListener("SIGINT", onSigint); } catch { /* ignore */ }
|
|
826
|
+
try { process.removeListener("SIGTERM", onSigterm); } catch { /* ignore */ }
|
|
827
|
+
_signalHandlersRegistered = false;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function onSigint(): void {
|
|
831
|
+
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
832
|
+
console.log("\n[workflow] SIGINT received, cancelling workflow...");
|
|
833
|
+
cancelWorkflow();
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function onSigterm(): void {
|
|
838
|
+
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
839
|
+
cancelWorkflow();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function registerSignalHandlers(): void {
|
|
844
|
+
if (_signalHandlersRegistered) return;
|
|
845
|
+
try {
|
|
846
|
+
process.on("SIGINT", onSigint);
|
|
847
|
+
process.on("SIGTERM", onSigterm);
|
|
848
|
+
_signalHandlersRegistered = true;
|
|
849
|
+
} catch { /* ignore */ }
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// ── Cancel handler ──
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
// ═══════════════════════════════════════════════════════════════
|
|
856
|
+
// Step change rollback (for "back" navigation)
|
|
857
|
+
// ═══════════════════════════════════════════════════════════════
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Revert file changes made by a specific step index using git.
|
|
861
|
+
* This is called when the user selects "back" during step confirmation.
|
|
862
|
+
*
|
|
863
|
+
* - "edit" files: git checkout HEAD to restore to committed state
|
|
864
|
+
* - "new" files: delete the untracked file
|
|
865
|
+
* - "delete" files: git checkout HEAD to restore the deleted file
|
|
866
|
+
*/
|
|
867
|
+
function revertStepChanges(stepIndex: number): void {
|
|
868
|
+
const changesForStep = _workflowFileChanges.filter(c => c.stepIndex === stepIndex);
|
|
869
|
+
if (changesForStep.length === 0) return;
|
|
870
|
+
|
|
871
|
+
for (const change of changesForStep) {
|
|
872
|
+
try {
|
|
873
|
+
const fullPath = path.join(_workflowCwd, change.filePath);
|
|
874
|
+
switch (change.type) {
|
|
875
|
+
case "edit":
|
|
876
|
+
case "delete":
|
|
877
|
+
// Restore from git HEAD
|
|
878
|
+
execSync(`git checkout HEAD -- "${change.filePath}"`, {
|
|
879
|
+
cwd: _workflowCwd,
|
|
880
|
+
encoding: "utf8",
|
|
881
|
+
timeout: 5000,
|
|
882
|
+
timeoutKill: 1000,
|
|
883
|
+
});
|
|
884
|
+
break;
|
|
885
|
+
case "new":
|
|
886
|
+
// Delete the newly created file
|
|
887
|
+
try {
|
|
888
|
+
if (fs.existsSync(fullPath)) {
|
|
889
|
+
fs.rmSync(fullPath, { force: true });
|
|
890
|
+
}
|
|
891
|
+
} catch { /* ignore if file doesn't exist */ }
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
} catch { /* if git fails, skip this file */ }
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Remove the reverted changes from the tracking list
|
|
898
|
+
_workflowFileChanges = _workflowFileChanges.filter(c => c.stepIndex !== stepIndex);
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ═══════════════════════════════════════════════════════════════
|
|
902
|
+
// Agent runner (non-blocking, widget-based)
|
|
903
|
+
// ═══════════════════════════════════════════════════════════════
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Run a sub-agent without blocking the main TUI.
|
|
907
|
+
* Progress is reported via the widget sub-step system.
|
|
908
|
+
* Uses the global AbortController for cancellation.
|
|
909
|
+
*/
|
|
910
|
+
async function runAgentWithProgress(
|
|
911
|
+
agent: AgentDef,
|
|
912
|
+
task: string,
|
|
913
|
+
stepIndex: number,
|
|
914
|
+
agentName: string,
|
|
915
|
+
timeoutMs: number,
|
|
916
|
+
): Promise<SubagentResult> {
|
|
917
|
+
const signal = _workflowAbortController?.signal;
|
|
918
|
+
const agentStartTime = Date.now();
|
|
919
|
+
|
|
920
|
+
// Initialize sub-step in widget
|
|
921
|
+
const step = _widgetSteps[stepIndex];
|
|
922
|
+
if (step) {
|
|
923
|
+
if (!step.subSteps) step.subSteps = [];
|
|
924
|
+
const existing = step.subSteps.find(s => s.agent === agentName);
|
|
925
|
+
if (!existing) {
|
|
926
|
+
step.subSteps.push({
|
|
927
|
+
agent: agentName,
|
|
928
|
+
status: "running",
|
|
929
|
+
tools: [],
|
|
930
|
+
outputs: [],
|
|
931
|
+
startedAt: agentStartTime,
|
|
932
|
+
});
|
|
933
|
+
refreshWidget();
|
|
934
|
+
} else {
|
|
935
|
+
// Update existing sub-step status and startedAt
|
|
936
|
+
existing.status = "running";
|
|
937
|
+
existing.startedAt = agentStartTime;
|
|
938
|
+
refreshWidget();
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Parse progress messages for tool calls and outputs
|
|
943
|
+
// NOTE: In-progress parsing is best-effort and intentionally conservative.
|
|
944
|
+
// Final file change detection relies on git diff --name-status (see updateToolsFromGit below)
|
|
945
|
+
// which is 100% accurate and free of AI text noise.
|
|
946
|
+
const result = await spawnSubagent(agent, task, _workflowCwd, signal, timeoutMs, (progress) => {
|
|
947
|
+
// Try to parse tool calls from progress messages
|
|
948
|
+
// Only match if it looks like a file path (contains a dot or path separator)
|
|
949
|
+
const toolMatch = progress.match(/(edit|read|write|new|bash|grep|find|ls|delete|remove)\s*[::]\s*(\S+)/i);
|
|
950
|
+
if (toolMatch) {
|
|
951
|
+
const toolType = toolMatch[1]!.toLowerCase();
|
|
952
|
+
const target = toolMatch[2]!;
|
|
953
|
+
// Only classify as file operation if it's a file path-like string
|
|
954
|
+
if (target.includes(".") || target.includes("/") || target.includes("\\")) {
|
|
955
|
+
const gitStatus = toGitStatus(toolType);
|
|
956
|
+
addWidgetSubStepTool(stepIndex, agentName, `${gitStatus} ${target}`);
|
|
957
|
+
_widgetExtraToolCount++;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
// Detect output file paths — ONLY match explicit .pi-dev-output paths, never free-form text
|
|
961
|
+
const outputMatch = progress.match(/\.pi-dev-output\/[^\s,;)\]}]{5,}\.\w+/i);
|
|
962
|
+
if (outputMatch) {
|
|
963
|
+
const pathCandidate = outputMatch[0]!.trim();
|
|
964
|
+
if (pathCandidate.length > 15 && pathCandidate.length < 300) {
|
|
965
|
+
addWidgetSubStepOutput(stepIndex, agentName, pathCandidate);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
const agentDuration = Date.now() - agentStartTime;
|
|
971
|
+
|
|
972
|
+
// Record agent run in history
|
|
973
|
+
_workflowAgentRunHistory.push({
|
|
974
|
+
agent: agentName,
|
|
975
|
+
stepIndex,
|
|
976
|
+
startedAt: new Date(agentStartTime).toISOString(),
|
|
977
|
+
durationMs: agentDuration,
|
|
978
|
+
exitCode: result.exitCode,
|
|
979
|
+
toolCount: _widgetExtraToolCount,
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// ── Post-completion: parse subagent output for tool calls and file paths ──
|
|
983
|
+
// Progress messages from spawnSubagent rarely contain tool info,
|
|
984
|
+
// so we scan the full output after completion.
|
|
985
|
+
const allOutput = (result.output || "") + "\n" + (result.stderr || "");
|
|
986
|
+
const finalOutput = extractFinalOutput(result.output) || result.output;
|
|
987
|
+
const searchText = allOutput + "\n" + finalOutput;
|
|
988
|
+
|
|
989
|
+
// Detect file creation/modification patterns from agent's final output text
|
|
990
|
+
// The agent's response typically lists files using markdown backticks or bullet points
|
|
991
|
+
const filePatterns = [
|
|
992
|
+
// Markdown code blocks with file paths: `src/main.rs`, `path/to/file.ts`
|
|
993
|
+
/`([^`]+\.[a-zA-Z0-9_]+)`/g,
|
|
994
|
+
// Bullet points with file operation verbs: - Modify `src/main.rs`, * Created `file.ts`
|
|
995
|
+
/(?:^|\n)\s*[-*]\s*(?:modified|created|updated|edited|added|deleted|removed|changed|wrote|writes?)\s*[`"']?([^`"'\n,]+\.[a-zA-Z0-9_]+)[`"']?/gim,
|
|
996
|
+
// Descriptive: "I've modified src/main.rs", "reading config.json"
|
|
997
|
+
/(?:modified|created|updated|edited|added|deleted|removed|changed|wrote|write|writes|read|reads?)\s+(?:the\s+)?[`"']?([^`"'\n,]+\.[a-zA-Z0-9_]+)[`"']?/gi,
|
|
998
|
+
// Chinese patterns
|
|
999
|
+
/(?:编写|创建|修改|删除|读取|写入|更新)\s*(?:了|文件)?\s*[::]?\s*[`"']?([^`"'\s,,]+\.[a-zA-Z0-9_]+)[`"']?/gi,
|
|
1000
|
+
// File path with action prefix: "edit: src/file.ts", "new: src/file.ts"
|
|
1001
|
+
/(?:^|\n)\s*(?:edit|new|delete|read|modify|create|update|add|remove)\s*[::]\s*([^\n]+\.[a-zA-Z0-9_]+)/gim,
|
|
1002
|
+
];
|
|
1003
|
+
const seenTools = new Set<string>();
|
|
1004
|
+
for (const pattern of filePatterns) {
|
|
1005
|
+
let m;
|
|
1006
|
+
while ((m = pattern.exec(searchText)) !== null) {
|
|
1007
|
+
const filePath = m[1]!.trim()
|
|
1008
|
+
.replace(/[`'"\)\(\]]+$/, "")
|
|
1009
|
+
.replace(/^[`'"\)\(\[]+/, "")
|
|
1010
|
+
.split(/[\s,;]/)[0]!;
|
|
1011
|
+
// Validate it's a real file path
|
|
1012
|
+
if (filePath.length > 3 && filePath.length < 300 && !seenTools.has(filePath)) {
|
|
1013
|
+
// Skip common non-file matches
|
|
1014
|
+
if (filePath.match(/^(the|a|an|this|that|it|its|my|your|our|their|some|any|all|each|every|both|few|many|several|most|other|another|such|what|which|whose|whom|when|where|why|how|who|being|having|doing|making|taking|giving|getting|setting|using|running|going|coming|looking|finding|keeping|putting)/i)) continue;
|
|
1015
|
+
if (filePath.startsWith("http")) continue;
|
|
1016
|
+
if (filePath.length < 6 && !filePath.includes("/")) continue;
|
|
1017
|
+
|
|
1018
|
+
seenTools.add(filePath);
|
|
1019
|
+
const fullMatch = m[0]!.toLowerCase();
|
|
1020
|
+
// Determine operation type and convert to git status
|
|
1021
|
+
let toolType = "edit";
|
|
1022
|
+
if (fullMatch.includes("write") || fullMatch.includes("创建") || fullMatch.includes("new") || fullMatch.includes("add") || fullMatch.includes("created") || fullMatch.includes("added")) {
|
|
1023
|
+
toolType = "new";
|
|
1024
|
+
} else if (fullMatch.includes("delete") || fullMatch.includes("删除") || fullMatch.includes("remove") || fullMatch.includes("deleted") || fullMatch.includes("removed")) {
|
|
1025
|
+
toolType = "delete";
|
|
1026
|
+
} else if (fullMatch.includes("read") || fullMatch.includes("读取")) {
|
|
1027
|
+
toolType = "read";
|
|
1028
|
+
}
|
|
1029
|
+
if (toolType !== "read") {
|
|
1030
|
+
const gitStatus = toGitStatus(toolType);
|
|
1031
|
+
addWidgetSubStepTool(stepIndex, agentName, `${gitStatus} ${filePath}`);
|
|
1032
|
+
_widgetExtraToolCount++;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// If we found no file tools from text patterns, try alternative approaches
|
|
1039
|
+
// Look for explicit tool call patterns in the raw JSON output
|
|
1040
|
+
if (seenTools.size === 0) {
|
|
1041
|
+
const jsonLines = (result.output || "").split("\n");
|
|
1042
|
+
for (const line of jsonLines) {
|
|
1043
|
+
try {
|
|
1044
|
+
const event = JSON.parse(line);
|
|
1045
|
+
// Look for tool_use events in the JSON stream
|
|
1046
|
+
if (event.type === "message_update" && event.assistantMessageEvent?.type === "tool_use") {
|
|
1047
|
+
const toolName = event.assistantMessageEvent.name;
|
|
1048
|
+
const args = event.assistantMessageEvent.args || {};
|
|
1049
|
+
// write tool: args contains file_path
|
|
1050
|
+
if (toolName === "write" && args.file_path) {
|
|
1051
|
+
const fp = args.file_path.trim();
|
|
1052
|
+
if (!seenTools.has(fp)) {
|
|
1053
|
+
seenTools.add(fp);
|
|
1054
|
+
addWidgetSubStepTool(stepIndex, agentName, `A ${fp}`);
|
|
1055
|
+
_widgetExtraToolCount++;
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
// edit tool: args contains file_path
|
|
1059
|
+
if (toolName === "edit" && args.file_path) {
|
|
1060
|
+
const fp = args.file_path.trim();
|
|
1061
|
+
if (!seenTools.has(fp)) {
|
|
1062
|
+
seenTools.add(fp);
|
|
1063
|
+
addWidgetSubStepTool(stepIndex, agentName, `M ${fp}`);
|
|
1064
|
+
_widgetExtraToolCount++;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
} catch { /* not JSON, skip */ }
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Find output file paths (.pi-dev-output, review reports, plan files)
|
|
1073
|
+
// NOTE: Only match explicit paths in .pi-dev-output/ or known review/plan file patterns.
|
|
1074
|
+
// The old pattern matching loose "output:" text was the root cause of "output::0.00004508" noise.
|
|
1075
|
+
// File change detection now relies on git diff --name-status (updateToolsFromGit below),
|
|
1076
|
+
// which is deterministic and noise-free.
|
|
1077
|
+
const outputPathPatterns = [
|
|
1078
|
+
// Direct reference to .pi-dev-output paths: ".pi-dev-output/pi-plans/xxx.md"
|
|
1079
|
+
/\.pi-dev-output\/[a-zA-Z0-9_\/-]+\.[a-zA-Z0-9]+/g,
|
|
1080
|
+
// Review file patterns: "review-20260520-162800.md"
|
|
1081
|
+
/review-\d{8}-\d{6}\.md/g,
|
|
1082
|
+
// Plan file patterns: "20260520-1628-*.md"
|
|
1083
|
+
/\d{8}-\d{4,6}-[a-zA-Z0-9_-]+\.md/g,
|
|
1084
|
+
];
|
|
1085
|
+
const seenOutputs = new Set<string>();
|
|
1086
|
+
for (const pattern of outputPathPatterns) {
|
|
1087
|
+
let m;
|
|
1088
|
+
while ((m = pattern.exec(searchText)) !== null) {
|
|
1089
|
+
const path_ = m[0]!.trim();
|
|
1090
|
+
if (path_.length > 10 && path_.length < 300 && !seenOutputs.has(path_)) {
|
|
1091
|
+
seenOutputs.add(path_);
|
|
1092
|
+
addWidgetSubStepOutput(stepIndex, agentName, path_);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
// ── Update file changes from git diff (more accurate than text scraping) ──
|
|
1099
|
+
updateToolsFromGit(_workflowCwd, stepIndex, agentName);
|
|
1100
|
+
// Update sub-step status based on result
|
|
1101
|
+
const subStatus: WorkflowSubStepWidgetState["status"] =
|
|
1102
|
+
result.exitCode === 0 ? "done" :
|
|
1103
|
+
isTimeoutResult(result) ? "failed" :
|
|
1104
|
+
result.exitCode !== 0 ? "failed" :
|
|
1105
|
+
"done";
|
|
1106
|
+
setWidgetSubStepStatus(stepIndex, agentName, subStatus);
|
|
1107
|
+
|
|
1108
|
+
return result;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1112
|
+
// Single-step executor
|
|
1113
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1114
|
+
|
|
1115
|
+
async function executeSingleStep(
|
|
1116
|
+
ctx: ExtensionCommandContext,
|
|
1117
|
+
step: WorkflowStepDef,
|
|
1118
|
+
state: WorkflowStepState,
|
|
1119
|
+
agentMap: Map<string, AgentDef>,
|
|
1120
|
+
prompt: string,
|
|
1121
|
+
planFileRelPath: string | undefined,
|
|
1122
|
+
mode: WorkflowMode,
|
|
1123
|
+
stepIndex: number,
|
|
1124
|
+
): Promise<void> {
|
|
1125
|
+
const agentName = step.agentName!;
|
|
1126
|
+
const agent = agentMap.get(agentName);
|
|
1127
|
+
if (!agent) throw new Error(`未找到 agent: ${agentName}`);
|
|
1128
|
+
|
|
1129
|
+
const task = buildTaskForStep(agentName, prompt, planFileRelPath, _workflowCwd);
|
|
1130
|
+
let retried = false;
|
|
1131
|
+
|
|
1132
|
+
let result = await runAgentWithProgress(agent, task, stepIndex, agentName, step.timeoutMs);
|
|
1133
|
+
|
|
1134
|
+
// Timeout handling
|
|
1135
|
+
if (isTimeoutResult(result)) {
|
|
1136
|
+
if (mode === "full-auto" && !retried) {
|
|
1137
|
+
result = await runAgentWithProgress(agent, `[RETRY]\n\n${task}`, stepIndex, agentName, step.timeoutMs);
|
|
1138
|
+
retried = true;
|
|
1139
|
+
} else {
|
|
1140
|
+
const choice = await uiSelect(ctx, `⏰ ${step.label} 执行超时`, [
|
|
1141
|
+
"1. 重新执行", "2. 跳过此步骤", "3. 取消工作流",
|
|
1142
|
+
]);
|
|
1143
|
+
if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
|
|
1144
|
+
if (choice.startsWith("2")) { state.status = "skipped"; return; }
|
|
1145
|
+
result = await runAgentWithProgress(agent, `[RETRY]\n\n${task}`, stepIndex, agentName, step.timeoutMs);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (isTimeoutResult(result)) {
|
|
1150
|
+
throw new Error(`执行超时 (${(step.timeoutMs / 1000).toFixed(0)}s)`);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (result.exitCode !== 0 && result.stderr) {
|
|
1154
|
+
throw new Error(`Agent 错误 (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1159
|
+
// Loop-group executor
|
|
1160
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1161
|
+
|
|
1162
|
+
async function executeLoopGroup(
|
|
1163
|
+
ctx: ExtensionCommandContext,
|
|
1164
|
+
step: WorkflowStepDef,
|
|
1165
|
+
state: WorkflowStepState,
|
|
1166
|
+
agentMap: Map<string, AgentDef>,
|
|
1167
|
+
prompt: string,
|
|
1168
|
+
planFileRelPath: string | undefined,
|
|
1169
|
+
mode: WorkflowMode,
|
|
1170
|
+
loopCounts: Record<string, number>,
|
|
1171
|
+
stepIndex: number,
|
|
1172
|
+
): Promise<void> {
|
|
1173
|
+
const loopAgent = agentMap.get(step.loopAgentName!);
|
|
1174
|
+
const reviewAgent = agentMap.get(step.reviewAgentName!);
|
|
1175
|
+
if (!loopAgent) throw new Error(`未找到 loop agent: ${step.loopAgentName}`);
|
|
1176
|
+
if (!reviewAgent) throw new Error(`未找到 review agent: ${step.reviewAgentName}`);
|
|
1177
|
+
|
|
1178
|
+
const maxLoops = step.maxLoops ?? 3;
|
|
1179
|
+
let loopCount = loopCounts[step.id] ?? 0;
|
|
1180
|
+
let contextPrompt = prompt;
|
|
1181
|
+
|
|
1182
|
+
while (loopCount < maxLoops) {
|
|
1183
|
+
const loopStartTime = Date.now();
|
|
1184
|
+
|
|
1185
|
+
// Run loop agent
|
|
1186
|
+
const loopTask = buildTaskForStep(step.loopAgentName!, contextPrompt, planFileRelPath, _workflowCwd);
|
|
1187
|
+
|
|
1188
|
+
let agentResult = await runAgentWithProgress(loopAgent, loopTask, stepIndex, step.loopAgentName!, step.timeoutMs);
|
|
1189
|
+
|
|
1190
|
+
// 检查 agent 是否异常退出(非超时非零退出码)
|
|
1191
|
+
while (agentResult.exitCode !== 0 && !isTimeoutResult(agentResult)) {
|
|
1192
|
+
if (mode === "full-auto") {
|
|
1193
|
+
throw new Error(`Agent ${step.loopAgentName} 异常退出 (exit ${agentResult.exitCode}): ${agentResult.stderr.slice(0, 200)}`);
|
|
1194
|
+
} else {
|
|
1195
|
+
const choice = await uiSelect(ctx, `❌ ${step.loopAgentName} 异常退出 (exit ${agentResult.exitCode})`, [
|
|
1196
|
+
"1. 重新执行", "2. 跳过此步骤", "3. 取消工作流",
|
|
1197
|
+
]);
|
|
1198
|
+
if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
|
|
1199
|
+
if (choice.startsWith("2")) { state.status = "skipped"; return; }
|
|
1200
|
+
// 重新执行
|
|
1201
|
+
agentResult = await runAgentWithProgress(loopAgent, `[RETRY]\n\n${loopTask}`, stepIndex, step.loopAgentName!, step.timeoutMs);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (isTimeoutResult(agentResult)) {
|
|
1206
|
+
if (mode === "full-auto") {
|
|
1207
|
+
contextPrompt = `[TIMEOUT_WARNING] 上一个 ${step.loopAgentName} 执行超时。\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd)}`;
|
|
1208
|
+
} else {
|
|
1209
|
+
const choice = await uiSelect(ctx, `⏰ ${step.loopAgentName} 执行超时`, [
|
|
1210
|
+
"1. 重新执行", "2. 进入审查阶段", "3. 跳过此步骤", "4. 取消工作流",
|
|
1211
|
+
]);
|
|
1212
|
+
if (!choice || choice.startsWith("4")) { cancelWorkflow(); return; }
|
|
1213
|
+
if (choice.startsWith("3")) { state.status = "skipped"; return; }
|
|
1214
|
+
if (choice.startsWith("2")) {
|
|
1215
|
+
contextPrompt = `[TIMEOUT_WARNING]\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd)}`;
|
|
1216
|
+
} else {
|
|
1217
|
+
agentResult = await runAgentWithProgress(loopAgent, `[RETRY]\n\n${loopTask}`, stepIndex, step.loopAgentName!, step.timeoutMs);
|
|
1218
|
+
if (isTimeoutResult(agentResult)) {
|
|
1219
|
+
contextPrompt = `[TIMEOUT_WARNING]\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd)}`;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// Run reviewer
|
|
1226
|
+
const reviewTask = contextPrompt.includes("[TIMEOUT_WARNING]")
|
|
1227
|
+
? contextPrompt
|
|
1228
|
+
: buildReviewTask(contextPrompt, planFileRelPath, _workflowCwd);
|
|
1229
|
+
|
|
1230
|
+
const reviewResult = await runAgentWithProgress(reviewAgent, reviewTask, stepIndex, step.reviewAgentName!, step.timeoutMs);
|
|
1231
|
+
|
|
1232
|
+
const extractedOutput = extractFinalOutput(reviewResult.output) || reviewResult.output;
|
|
1233
|
+
const combinedOutput = extractedOutput + "\n" + reviewResult.stderr;
|
|
1234
|
+
let reviewSummary = parseReviewerOutput(combinedOutput);
|
|
1235
|
+
if (!reviewSummary) reviewSummary = extractSeverityFromText(extractedOutput);
|
|
1236
|
+
if (!reviewSummary) {
|
|
1237
|
+
const reviewContent = readLatestReviewMd(_workflowCwd);
|
|
1238
|
+
if (reviewContent) {
|
|
1239
|
+
reviewSummary = parseReviewerOutput(reviewContent) ?? extractSeverityFromText(reviewContent);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
loopCount++;
|
|
1244
|
+
|
|
1245
|
+
if (reviewSummary?.maxSeverity === "critical" && loopCount < maxLoops) {
|
|
1246
|
+
if (mode === "full-auto") {
|
|
1247
|
+
contextPrompt = [prompt, "", "## 上次审查发现的问题",
|
|
1248
|
+
`审查摘要: ${JSON.stringify(reviewSummary)}`,
|
|
1249
|
+
`请修复 ${reviewSummary.critical} 个严重问题后重新运行。`,
|
|
1250
|
+
].join("\n");
|
|
1251
|
+
continue;
|
|
1252
|
+
} else {
|
|
1253
|
+
const shouldLoop = await uiConfirm(ctx, "🔄 检测到严重问题",
|
|
1254
|
+
`审查发现 ${reviewSummary.critical} 个严重问题。是否进入下一轮循环 (${loopCount}/${maxLoops})?`);
|
|
1255
|
+
if (shouldLoop) {
|
|
1256
|
+
contextPrompt = [prompt, "", "## 上次审查发现的问题",
|
|
1257
|
+
`审查摘要: ${JSON.stringify(reviewSummary)}`,
|
|
1258
|
+
`请修复这些严重问题后重新运行。`,
|
|
1259
|
+
].join("\n");
|
|
1260
|
+
continue;
|
|
1261
|
+
}
|
|
1262
|
+
break;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
break;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
state.loopCount = loopCount;
|
|
1269
|
+
loopCounts[step.id] = loopCount;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1273
|
+
// Main async workflow executor
|
|
1274
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1275
|
+
|
|
1276
|
+
async function executeWorkflowBackground(
|
|
1277
|
+
ctx: ExtensionCommandContext,
|
|
1278
|
+
pi: ExtensionAPI,
|
|
1279
|
+
prompt: string,
|
|
1280
|
+
steps: WorkflowStepDef[],
|
|
1281
|
+
agentMap: Map<string, AgentDef>,
|
|
1282
|
+
mode: WorkflowMode,
|
|
1283
|
+
stepStates: WorkflowStepState[],
|
|
1284
|
+
initialStepIndex: number,
|
|
1285
|
+
initialLoopCounts: Record<string, number>,
|
|
1286
|
+
planFileRelPath: string | undefined,
|
|
1287
|
+
existingCp: CheckpointData | undefined,
|
|
1288
|
+
): Promise<void> {
|
|
1289
|
+
let loopCounts = { ...initialLoopCounts };
|
|
1290
|
+
let currentStepIndex = initialStepIndex;
|
|
1291
|
+
let planFileRelPathInner = planFileRelPath;
|
|
1292
|
+
|
|
1293
|
+
for (; currentStepIndex < steps.length; currentStepIndex++) {
|
|
1294
|
+
// Check abort
|
|
1295
|
+
if (_workflowAbortController?.signal.aborted) {
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
const step = steps[currentStepIndex]!;
|
|
1300
|
+
const state = stepStates[currentStepIndex]!;
|
|
1301
|
+
|
|
1302
|
+
if (state.status === "done" || state.status === "skipped") continue;
|
|
1303
|
+
|
|
1304
|
+
setWidgetCurrentStep(currentStepIndex);
|
|
1305
|
+
|
|
1306
|
+
// Pre-populate sub-steps for pending steps so UI shows queued agents
|
|
1307
|
+
populatePredefinedSubSteps(currentStepIndex);
|
|
1308
|
+
|
|
1309
|
+
// ── Helper: handle "back" from any confirmation dialog ──
|
|
1310
|
+
// Returns true if back was handled (caller should continue without execution).
|
|
1311
|
+
const handleBack = async (): Promise<boolean> => {
|
|
1312
|
+
if (currentStepIndex > 0) {
|
|
1313
|
+
// Revert file changes made by the previous step
|
|
1314
|
+
revertStepChanges(currentStepIndex - 1);
|
|
1315
|
+
// Reset previous step state
|
|
1316
|
+
stepStates[currentStepIndex - 1]!.status = "pending";
|
|
1317
|
+
stepStates[currentStepIndex - 1]!.durationMs = undefined;
|
|
1318
|
+
stepStates[currentStepIndex - 1]!.error = undefined;
|
|
1319
|
+
stepStates[currentStepIndex - 1]!.loopCount = undefined;
|
|
1320
|
+
updateWidgetStep(
|
|
1321
|
+
currentStepIndex - 1,
|
|
1322
|
+
steps[currentStepIndex - 1]!.label,
|
|
1323
|
+
"pending",
|
|
1324
|
+
);
|
|
1325
|
+
// Clear sub-steps for the reverted step
|
|
1326
|
+
if (_widgetSteps[currentStepIndex - 1]) {
|
|
1327
|
+
_widgetSteps[currentStepIndex - 1]!.subSteps = [];
|
|
1328
|
+
}
|
|
1329
|
+
currentStepIndex -= 2; // loop will ++, landing on the previous step
|
|
1330
|
+
saveCheckpoint(_workflowCwd, buildCp());
|
|
1331
|
+
return true;
|
|
1332
|
+
}
|
|
1333
|
+
return false;
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
// ── User confirmations (BEFORE timer starts) ──
|
|
1337
|
+
|
|
1338
|
+
// Confirmation for [confirm] steps (attended mode)
|
|
1339
|
+
if (step.type === "confirm" && mode !== "full-auto") {
|
|
1340
|
+
const items = [
|
|
1341
|
+
"1. 进入此步骤", "2. 自定义输入", "3. 跳过此步骤", "4. 取消工作流",
|
|
1342
|
+
];
|
|
1343
|
+
if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT);
|
|
1344
|
+
const choice = await uiSelect(ctx, `📌 ${step.label}`, items);
|
|
1345
|
+
if (choice === BACK_OPTION_TEXT) {
|
|
1346
|
+
if (await handleBack()) continue;
|
|
1347
|
+
return; // can't go back from first step
|
|
1348
|
+
}
|
|
1349
|
+
if (!choice || choice.startsWith("4")) { cancelWorkflow(); return; }
|
|
1350
|
+
if (choice.startsWith("3")) {
|
|
1351
|
+
state.status = "skipped";
|
|
1352
|
+
saveCheckpoint(_workflowCwd, buildCp());
|
|
1353
|
+
updateWidgetStep(currentStepIndex, step.label, "skipped");
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
if (choice.startsWith("2")) {
|
|
1357
|
+
const customInput = await uiInput(ctx, "✏️ 自定义输入", "输入你的指令或反馈");
|
|
1358
|
+
if (customInput !== undefined && customInput.trim()) {
|
|
1359
|
+
prompt = `${prompt}\n\n## 用户自定义指令\n${customInput.trim()}`;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Full-attended: confirm every step
|
|
1365
|
+
if (mode === "full-attended" && step.type !== "confirm") {
|
|
1366
|
+
const items = ["1. 执行", "2. 跳过", "3. 取消工作流"];
|
|
1367
|
+
if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT);
|
|
1368
|
+
const choice = await uiSelect(ctx, `📌 ${step.label} — 执行?`, items);
|
|
1369
|
+
if (choice === BACK_OPTION_TEXT) {
|
|
1370
|
+
if (await handleBack()) continue;
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
|
|
1374
|
+
if (choice.startsWith("2")) {
|
|
1375
|
+
state.status = "skipped";
|
|
1376
|
+
saveCheckpoint(_workflowCwd, buildCp());
|
|
1377
|
+
updateWidgetStep(currentStepIndex, step.label, "skipped");
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Attended: confirm loop-group steps (e.g. 实施代码 → 审查)
|
|
1383
|
+
if (step.type === "loop-group" && mode === "attended") {
|
|
1384
|
+
const items = [
|
|
1385
|
+
"1. 进入此步骤", "2. 跳过此步骤", "3. 取消工作流",
|
|
1386
|
+
];
|
|
1387
|
+
if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT);
|
|
1388
|
+
const choice = await uiSelect(ctx, `📌 ${step.label}`, items);
|
|
1389
|
+
if (choice === BACK_OPTION_TEXT) {
|
|
1390
|
+
if (await handleBack()) continue;
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; }
|
|
1394
|
+
if (choice.startsWith("2")) {
|
|
1395
|
+
state.status = "skipped";
|
|
1396
|
+
saveCheckpoint(_workflowCwd, buildCp());
|
|
1397
|
+
updateWidgetStep(currentStepIndex, step.label, "skipped");
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ── Execute (timer starts NOW, after all user confirmations) ──
|
|
1403
|
+
state.status = "running";
|
|
1404
|
+
const stepStartTime = Date.now();
|
|
1405
|
+
updateWidgetStep(currentStepIndex, step.label, "running", { timeoutMs: step.timeoutMs, maxLoops: step.maxLoops, startedAt: stepStartTime });
|
|
1406
|
+
|
|
1407
|
+
try {
|
|
1408
|
+
if (step.type === "loop-group") {
|
|
1409
|
+
await executeLoopGroup(ctx, step, state, agentMap, prompt, planFileRelPathInner, mode, loopCounts, currentStepIndex);
|
|
1410
|
+
} else {
|
|
1411
|
+
await executeSingleStep(ctx, step, state, agentMap, prompt, planFileRelPathInner, mode, currentStepIndex);
|
|
1412
|
+
if (step.agentName === "planner") {
|
|
1413
|
+
planFileRelPathInner = findLatestPlanFile(_workflowCwd);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
state.status = "done";
|
|
1417
|
+
state.durationMs = Date.now() - stepStartTime;
|
|
1418
|
+
updateWidgetStep(currentStepIndex, step.label, "done", {
|
|
1419
|
+
durationMs: state.durationMs,
|
|
1420
|
+
loopCount: state.loopCount,
|
|
1421
|
+
maxLoops: step.maxLoops,
|
|
1422
|
+
timeoutMs: step.timeoutMs,
|
|
1423
|
+
});
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
state.status = "failed";
|
|
1426
|
+
state.durationMs = Date.now() - stepStartTime;
|
|
1427
|
+
state.error = err instanceof Error ? err.message : String(err);
|
|
1428
|
+
updateWidgetStep(currentStepIndex, step.label, "failed", {
|
|
1429
|
+
durationMs: state.durationMs,
|
|
1430
|
+
error: state.error,
|
|
1431
|
+
loopCount: state.loopCount,
|
|
1432
|
+
});
|
|
1433
|
+
break;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
setWidgetCurrentStep(currentStepIndex + 1);
|
|
1437
|
+
saveCheckpoint(_workflowCwd, buildCp());
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ── Done ──
|
|
1441
|
+
_workflowRunning = false;
|
|
1442
|
+
|
|
1443
|
+
// Archive checkpoint instead of deleting
|
|
1444
|
+
archiveCheckpointFile(_workflowCwd, planFileRelPathInner);
|
|
1445
|
+
|
|
1446
|
+
// Send persistent result
|
|
1447
|
+
const taskSummary = extractTaskSummary(prompt);
|
|
1448
|
+
const finalState = buildWidgetState(
|
|
1449
|
+
mode,
|
|
1450
|
+
_widgetSteps,
|
|
1451
|
+
steps.length,
|
|
1452
|
+
_widgetStartTime,
|
|
1453
|
+
stepStates.every(s => s.status === "done" || s.status === "skipped") ? "done" : "failed",
|
|
1454
|
+
{ toolCount: _widgetExtraToolCount, tokenCount: _widgetExtraTokenCount },
|
|
1455
|
+
taskSummary,
|
|
1456
|
+
);
|
|
1457
|
+
sendWorkflowResult(pi, finalState, prompt, _workflowType);
|
|
1458
|
+
|
|
1459
|
+
// Cleanup widget after delay
|
|
1460
|
+
if (_cleanupTimer) clearTimeout(_cleanupTimer);
|
|
1461
|
+
_cleanupTimer = setTimeout(() => {
|
|
1462
|
+
_cleanupTimer = null;
|
|
1463
|
+
cleanupWidget();
|
|
1464
|
+
}, 5000);
|
|
1465
|
+
|
|
1466
|
+
function buildCp(): CheckpointData {
|
|
1467
|
+
return {
|
|
1468
|
+
version: 2,
|
|
1469
|
+
createdAt: existingCp?.createdAt ?? new Date().toISOString(),
|
|
1470
|
+
updatedAt: new Date().toISOString(),
|
|
1471
|
+
prompt,
|
|
1472
|
+
mode,
|
|
1473
|
+
steps: stepStates,
|
|
1474
|
+
currentStepIndex,
|
|
1475
|
+
loopCounts,
|
|
1476
|
+
planFilePath: planFileRelPathInner,
|
|
1477
|
+
taskSummary: extractTaskSummary(prompt),
|
|
1478
|
+
workflowType: _workflowType,
|
|
1479
|
+
fileChanges: [..._workflowFileChanges],
|
|
1480
|
+
subAgentRuns: _workflowAgentRunHistory.length,
|
|
1481
|
+
filesModified: _workflowFileChanges.filter(c => c.type === "edit").length,
|
|
1482
|
+
filesCreated: _workflowFileChanges.filter(c => c.type === "new").length,
|
|
1483
|
+
agentRunHistory: [..._workflowAgentRunHistory],
|
|
1484
|
+
baseline: _workflowBaseline,
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1490
|
+
// Main entry
|
|
1491
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1492
|
+
|
|
1493
|
+
export interface WorkflowConfig {
|
|
1494
|
+
steps: WorkflowStepDef[];
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Launch a workflow asynchronously.
|
|
1499
|
+
* Sets up the widget and runs steps in background.
|
|
1500
|
+
* Does NOT block - the caller (command handler) returns immediately.
|
|
1501
|
+
*
|
|
1502
|
+
* @param ctx 命令上下文
|
|
1503
|
+
* @param pi Extension API
|
|
1504
|
+
* @param prompt 用户原始 prompt
|
|
1505
|
+
* @param config 工作流步骤配置
|
|
1506
|
+
* @param workflowType 可选的类型标签(feat/fix/doc 等),用于完成消息
|
|
1507
|
+
*/
|
|
1508
|
+
export async function runWorkflow(
|
|
1509
|
+
ctx: ExtensionCommandContext,
|
|
1510
|
+
pi: ExtensionAPI,
|
|
1511
|
+
prompt: string,
|
|
1512
|
+
config: WorkflowConfig,
|
|
1513
|
+
workflowType?: string,
|
|
1514
|
+
): Promise<void> {
|
|
1515
|
+
const { steps } = config;
|
|
1516
|
+
|
|
1517
|
+
// ── Load & validate agents ──
|
|
1518
|
+
const agents = discoverAgents();
|
|
1519
|
+
const agentMap = new Map<string, AgentDef>();
|
|
1520
|
+
for (const a of agents) agentMap.set(a.name, a);
|
|
1521
|
+
|
|
1522
|
+
for (const step of steps) {
|
|
1523
|
+
const names: string[] = [];
|
|
1524
|
+
if (step.type === "auto" || step.type === "confirm") {
|
|
1525
|
+
if (step.agentName) names.push(step.agentName);
|
|
1526
|
+
}
|
|
1527
|
+
if (step.type === "loop-group") {
|
|
1528
|
+
if (step.loopAgentName) names.push(step.loopAgentName);
|
|
1529
|
+
if (step.reviewAgentName) names.push(step.reviewAgentName);
|
|
1530
|
+
}
|
|
1531
|
+
for (const n of names) {
|
|
1532
|
+
if (!agentMap.has(n)) {
|
|
1533
|
+
await uiSelect(ctx, `❌ 未找到 agent "${n}"`, ["确定"]);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// ── State init / checkpoint recovery ──
|
|
1540
|
+
let mode: WorkflowMode = "attended";
|
|
1541
|
+
const stepStates: WorkflowStepState[] = steps.map(() => ({ status: "pending" }));
|
|
1542
|
+
let currentStepIndex = 0;
|
|
1543
|
+
let loopCounts: Record<string, number> = {};
|
|
1544
|
+
let planFileRelPath: string | undefined;
|
|
1545
|
+
let resumeFlow = false;
|
|
1546
|
+
|
|
1547
|
+
const existingCp = loadCheckpointFromFile(ctx.cwd);
|
|
1548
|
+
if (existingCp) {
|
|
1549
|
+
const resume = await uiConfirm(ctx, "🔄 恢复工作流",
|
|
1550
|
+
`发现上次未完成的工作流(${existingCp.updatedAt}),是否继续?`);
|
|
1551
|
+
if (resume) {
|
|
1552
|
+
mode = existingCp.mode;
|
|
1553
|
+
Object.assign(stepStates, existingCp.steps);
|
|
1554
|
+
currentStepIndex = existingCp.currentStepIndex;
|
|
1555
|
+
loopCounts = existingCp.loopCounts;
|
|
1556
|
+
planFileRelPath = existingCp.planFilePath;
|
|
1557
|
+
resumeFlow = true;
|
|
1558
|
+
} else {
|
|
1559
|
+
archiveCheckpointFile(ctx.cwd); // archive old checkpoint
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
if (!existingCp || !resumeFlow) {
|
|
1564
|
+
const modeChoice = await uiSelect(ctx, "🤖 选择工作流模式", [
|
|
1565
|
+
"1. 值守(默认)— 自动流程,[]步骤需确认,循环需许可",
|
|
1566
|
+
"2. 完全信任 — 全自动运行,无需任何确认",
|
|
1567
|
+
"3. 完全值守 — 每一步都需用户确认",
|
|
1568
|
+
"4. 取消工作流",
|
|
1569
|
+
]);
|
|
1570
|
+
if (!modeChoice || modeChoice.startsWith("4")) return;
|
|
1571
|
+
mode = modeChoice.startsWith("2") ? "full-auto" :
|
|
1572
|
+
modeChoice.startsWith("3") ? "full-attended" : "attended";
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Save initial state
|
|
1576
|
+
_workflowCwd = ctx.cwd;
|
|
1577
|
+
_workflowPrompt = prompt;
|
|
1578
|
+
_workflowPlanFileRelPath = planFileRelPath;
|
|
1579
|
+
_workflowLoopCounts = loopCounts;
|
|
1580
|
+
_workflowCreatedAt = existingCp?.createdAt ?? new Date().toISOString();
|
|
1581
|
+
_workflowType = workflowType;
|
|
1582
|
+
_workflowPi = pi;
|
|
1583
|
+
_workflowStepDefs = steps;
|
|
1584
|
+
_workflowFileChanges = existingCp?.fileChanges ? [...existingCp.fileChanges] : [];
|
|
1585
|
+
_workflowAgentRunHistory = existingCp?.agentRunHistory ? [...existingCp.agentRunHistory] : [];
|
|
1586
|
+
|
|
1587
|
+
// ── Baseline snapshot: restore from checkpoint or capture fresh ──
|
|
1588
|
+
if (resumeFlow && existingCp?.baseline) {
|
|
1589
|
+
_workflowBaseline = [...existingCp.baseline];
|
|
1590
|
+
} else if (!resumeFlow) {
|
|
1591
|
+
captureBaseline(ctx.cwd);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
saveCheckpoint(ctx.cwd, {
|
|
1595
|
+
version: 2,
|
|
1596
|
+
createdAt: existingCp?.createdAt ?? new Date().toISOString(),
|
|
1597
|
+
updatedAt: new Date().toISOString(),
|
|
1598
|
+
prompt,
|
|
1599
|
+
mode,
|
|
1600
|
+
steps: stepStates,
|
|
1601
|
+
currentStepIndex,
|
|
1602
|
+
loopCounts,
|
|
1603
|
+
planFilePath: planFileRelPath,
|
|
1604
|
+
taskSummary: extractTaskSummary(prompt),
|
|
1605
|
+
workflowType,
|
|
1606
|
+
fileChanges: [..._workflowFileChanges],
|
|
1607
|
+
subAgentRuns: _workflowAgentRunHistory.length,
|
|
1608
|
+
filesModified: _workflowFileChanges.filter(c => c.type === "edit").length,
|
|
1609
|
+
filesCreated: _workflowFileChanges.filter(c => c.type === "new").length,
|
|
1610
|
+
agentRunHistory: [..._workflowAgentRunHistory],
|
|
1611
|
+
baseline: _workflowBaseline,
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
// Initialize widget
|
|
1615
|
+
initWidget(ctx, mode, steps.length);
|
|
1616
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1617
|
+
const isDoneState = stepStates[i]?.status === "done";
|
|
1618
|
+
updateWidgetStep(i, steps[i]!.label, isDoneState ? "done" : "pending", {
|
|
1619
|
+
maxLoops: steps[i]!.maxLoops,
|
|
1620
|
+
timeoutMs: steps[i]!.timeoutMs,
|
|
1621
|
+
});
|
|
1622
|
+
// Pre-populate sub-steps for all steps (shows queued agents)
|
|
1623
|
+
populatePredefinedSubSteps(i);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Set up abort controller & cancel callback
|
|
1627
|
+
_workflowAbortController = new AbortController();
|
|
1628
|
+
setWorkflowCancelCallback(() => {
|
|
1629
|
+
_workflowAbortController?.abort();
|
|
1630
|
+
_workflowRunning = false;
|
|
1631
|
+
if (_lastWorkflowCtx) {
|
|
1632
|
+
const finalState = buildWidgetState(
|
|
1633
|
+
_widgetMode,
|
|
1634
|
+
_widgetSteps,
|
|
1635
|
+
_widgetCurrentIdx,
|
|
1636
|
+
_widgetStartTime,
|
|
1637
|
+
"cancelled",
|
|
1638
|
+
extractTaskSummary(_workflowPrompt),
|
|
1639
|
+
);
|
|
1640
|
+
updateWorkflowWidget(_lastWorkflowCtx, finalState);
|
|
1641
|
+
|
|
1642
|
+
// ── Save final checkpoint before archiving ──
|
|
1643
|
+
const cancelCp: CheckpointData = {
|
|
1644
|
+
version: 2,
|
|
1645
|
+
createdAt: _workflowCreatedAt,
|
|
1646
|
+
updatedAt: new Date().toISOString(),
|
|
1647
|
+
prompt: _workflowPrompt,
|
|
1648
|
+
mode: _widgetMode,
|
|
1649
|
+
steps: _widgetSteps.map(s => ({
|
|
1650
|
+
status: s.status as WorkflowStepState["status"],
|
|
1651
|
+
durationMs: s.durationMs,
|
|
1652
|
+
loopCount: s.loopCount,
|
|
1653
|
+
error: s.error,
|
|
1654
|
+
})),
|
|
1655
|
+
currentStepIndex: _widgetCurrentIdx,
|
|
1656
|
+
loopCounts: { ..._workflowLoopCounts },
|
|
1657
|
+
planFilePath: _workflowPlanFileRelPath,
|
|
1658
|
+
taskSummary: extractTaskSummary(_workflowPrompt),
|
|
1659
|
+
workflowType: _workflowType,
|
|
1660
|
+
fileChanges: [..._workflowFileChanges],
|
|
1661
|
+
subAgentRuns: _workflowAgentRunHistory.length,
|
|
1662
|
+
filesModified: _workflowFileChanges.filter(c => c.type === "edit").length,
|
|
1663
|
+
filesCreated: _workflowFileChanges.filter(c => c.type === "new").length,
|
|
1664
|
+
agentRunHistory: [..._workflowAgentRunHistory],
|
|
1665
|
+
baseline: _workflowBaseline,
|
|
1666
|
+
};
|
|
1667
|
+
saveCheckpoint(_workflowCwd, cancelCp);
|
|
1668
|
+
|
|
1669
|
+
// ── Send workflow result message for persistence ──
|
|
1670
|
+
if (_workflowPi) {
|
|
1671
|
+
sendWorkflowResult(_workflowPi, finalState, _workflowPrompt, _workflowType);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// ── Archive checkpoint on cancel too ──
|
|
1675
|
+
archiveCheckpointFile(_workflowCwd, _workflowPlanFileRelPath);
|
|
1676
|
+
if (_cleanupTimer) clearTimeout(_cleanupTimer);
|
|
1677
|
+
_cleanupTimer = setTimeout(() => {
|
|
1678
|
+
_cleanupTimer = null;
|
|
1679
|
+
cleanupWidget();
|
|
1680
|
+
}, 5000);
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
// Collapse tools to show widget
|
|
1685
|
+
ctx.ui.setToolsExpanded(false);
|
|
1686
|
+
|
|
1687
|
+
// ── Register terminal input handler (Esc to cancel) ──
|
|
1688
|
+
if (ctx.hasUI) {
|
|
1689
|
+
_terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
|
|
1690
|
+
if (!matchesKey(data, Key.escape)) return undefined;
|
|
1691
|
+
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
1692
|
+
ctx.ui.notify("⏹️ 用户取消工作流", "warning");
|
|
1693
|
+
cancelWorkflow();
|
|
1694
|
+
return { consume: true };
|
|
1695
|
+
}
|
|
1696
|
+
return undefined;
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// ── Register signal handlers (SIGINT/SIGTERM) for graceful shutdown ──
|
|
1701
|
+
registerSignalHandlers();
|
|
1702
|
+
|
|
1703
|
+
// ── Launch background execution (fire-and-forget) ──
|
|
1704
|
+
executeWorkflowBackground(
|
|
1705
|
+
ctx, pi, prompt, steps, agentMap, mode, stepStates,
|
|
1706
|
+
currentStepIndex, loopCounts, planFileRelPath,
|
|
1707
|
+
existingCp ?? undefined,
|
|
1708
|
+
).catch((err) => {
|
|
1709
|
+
console.error("[workflow] Background execution error:", err);
|
|
1710
|
+
_workflowRunning = false;
|
|
1711
|
+
if (_lastWorkflowCtx) {
|
|
1712
|
+
updateWorkflowWidget(_lastWorkflowCtx, null);
|
|
1713
|
+
}
|
|
1714
|
+
// Clean up terminal input listener and signal handlers
|
|
1715
|
+
if (_terminalInputUnsubscribe) {
|
|
1716
|
+
_terminalInputUnsubscribe();
|
|
1717
|
+
_terminalInputUnsubscribe = null;
|
|
1718
|
+
}
|
|
1719
|
+
cleanupSignalHandlers();
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
// Return immediately - execution continues in background
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1726
|
+
// Extension factory (no-op — imported by dev-prompts.ts)
|
|
1727
|
+
// ═══════════════════════════════════════════════════════════════
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* Check if a workflow is currently running.
|
|
1731
|
+
*/
|
|
1732
|
+
export function isWorkflowRunning(): boolean {
|
|
1733
|
+
return _workflowRunning;
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* Cancel the active workflow, if any.
|
|
1738
|
+
* Safe to call even when no workflow is running (no-op in that case).
|
|
1739
|
+
*/
|
|
1740
|
+
export function cancelActiveWorkflow(): void {
|
|
1741
|
+
if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) {
|
|
1742
|
+
cancelWorkflow();
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
export default function (_pi: ExtensionAPI) {
|
|
1747
|
+
// workflow-engine is a helper module, not a standalone extension.
|
|
1748
|
+
}
|