/** * workflow-engine.ts — 工作流编排引擎 * * 职责: * 1. runWorkflow() — 主入口,编排多步骤工作流(后台异步执行) * 2. 支持 值守/全自动/完全值守 三种模式 * 3. 支持 {} loop 组(worker→reviewer, trimmer→reviewer) * 4. 支持 [] 标记的确认步骤 * 5. Checkpoint 保存/恢复(断点续传) * 6. 超时处理(按 mode 策略分支) * 7. 进度面板 UI — 使用 ctx.ui.setWidget() 持久化面板,支持 Ctrl+O 展开 * * 被 dev-prompts.ts 引入,不独立作为 extension 加载。 * * 设计要点: * - 非阻塞执行:通过 AbortController 管理取消,widget 动画更新进度 * - 步骤详情:记录 agent 的工具调用、输出路径等子步骤信息 * - 归档:工作流完成后 checkpoint 重命名而非删除 */ import * as fs from "node:fs"; import * as path from "node:path"; import { randomBytes } from "node:crypto"; /** * Generate a UUID v7 (time-ordered) string. * Format: tttttttt-tttt-7xxx-yxxx-xxxxxxxxxxxx * - t = Unix timestamp (ms) as 48-bit big-endian * - 7 = version (0111) * - y = variant (10xx = 8, 9, a, b) * - x = random */ function uuidv7(): string { const ts = BigInt(Date.now()); const buf = new Uint8Array(16); // 48-bit timestamp (big-endian) in bytes 0-5 buf[0] = Number((ts >> 40n) & 0xFFn); buf[1] = Number((ts >> 32n) & 0xFFn); buf[2] = Number((ts >> 24n) & 0xFFn); buf[3] = Number((ts >> 16n) & 0xFFn); buf[4] = Number((ts >> 8n) & 0xFFn); buf[5] = Number(ts & 0xFFn); // 10 random bytes for version/variant/rand const r = randomBytes(10); // Byte 6: version (0111) | rand_a high 4 bits buf[6] = 0x70 | (r[0] >> 4); // Byte 7: rand_a low 8 bits buf[7] = (r[0] << 4) | (r[1] >> 4); // Byte 8: variant (10) | rand_b high 6 bits buf[8] = 0x80 | (r[2] >> 2); // Bytes 9-15: remaining rand_b (56 bits) buf[9] = r[3]; buf[10] = r[4]; buf[11] = r[5]; buf[12] = r[6]; buf[13] = r[7]; buf[14] = r[8]; buf[15] = r[9]; // Format as 8-4-4-4-12 hex UUID const hex = Array.from(buf, b => b.toString(16).padStart(2, '0')).join(''); return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey } from "@earendil-works/pi-tui"; import { execSync } from "child_process"; import { spawnSubagent, extractFinalOutput, discoverAgents, type AgentDef, type SubagentResult } from "./sub-agents"; import { uiSelect, uiConfirm, uiInput, updateWorkflowWidget, buildWidgetState, sendWorkflowResult, formatTimeout, setWorkflowCancelCallback, cancelWorkflow, BACK_MARKER, BACK_OPTION_TEXT, type WorkflowStepWidgetState, type WorkflowSubStepWidgetState, type WorkflowWidgetState, } from "./ui-helpers"; // ═══════════════════════════════════════════════════════════════ // Types // ═══════════════════════════════════════════════════════════════ export type WorkflowMode = "attended" | "full-auto" | "full-attended"; export interface WorkflowStepDef { id: string; label: string; type: "auto" | "confirm" | "loop-group"; agentName?: string; loopAgentName?: string; reviewAgentName?: string; maxLoops?: number; timeoutMs: number; /** 独立于 loopAgent 的 reviewer 超时时间(ms),默认使用 timeoutMs */ reviewTimeoutMs?: number; } interface WorkflowStepState { status: "pending" | "running" | "done" | "failed" | "skipped"; durationMs?: number; loopCount?: number; error?: string; } interface FileChangeEntry { agent: string; stepIndex: number; type: "edit" | "new" | "delete" | "read"; filePath: string; timestamp: string; } interface AgentRunEntry { agent: string; stepIndex: number; startedAt: string; durationMs: number; exitCode: number; toolCount: number; } /** * Baseline entry: records the git object hash of a file at workflow start. * Used to distinguish pre-existing dirty files from workflow-generated changes. */ interface BaselineEntry { path: string; hash: string; } interface CheckpointData { version: 2; /** UUID that uniquely identifies this workflow run across all output files */ workflowId: string; createdAt: string; updatedAt: string; prompt: string; mode: WorkflowMode; steps: WorkflowStepState[]; currentStepIndex: number; loopCounts: Record; planFilePath?: string; // New fields for better UI and traceability taskSummary?: string; workflowType?: string; fileChanges?: FileChangeEntry[]; subAgentRuns?: number; filesModified?: number; filesCreated?: number; agentRunHistory?: AgentRunEntry[]; /** Baseline snapshot taken at workflow start for accurate change tracking. */ baseline?: BaselineEntry[]; } // ═══════════════════════════════════════════════════════════════ // Constants // ═══════════════════════════════════════════════════════════════ const DEV_OUTPUT_DIR = ".pi-dev-output"; const CHECKPOINT_FILE = path.join(DEV_OUTPUT_DIR, "pi-workflow", "checkpoint.json"); const PLANS_DIR = path.join(DEV_OUTPUT_DIR, "pi-plans"); // ═══════════════════════════════════════════════════════════════ // Utility Helpers // ═══════════════════════════════════════════════════════════════ function ensureOutputDir(cwd: string, subdir: string): string { const dir = path.join(cwd, DEV_OUTPUT_DIR, subdir); fs.mkdirSync(dir, { recursive: true }); return dir; } function findLatestPlanFile(cwd: string): string | undefined { const dir = path.join(cwd, PLANS_DIR); try { if (!fs.existsSync(dir)) return undefined; const files = fs.readdirSync(dir) .filter(f => f.endsWith(".md")) .map(f => ({ name: f, mtime: fs.statSync(path.join(dir, f)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); return files.length > 0 ? path.join(PLANS_DIR, files[0].name) : undefined; } catch { return undefined; } } function readFileContent(cwd: string, relativePath: string): string | undefined { try { return fs.readFileSync(path.join(cwd, relativePath), "utf-8"); } catch { return undefined; } } export function parseReviewerOutput( output: string, ): { maxSeverity: string; critical: number; medium: number; low: number } | null { const match = output.match(/\[REVIEW_SUMMARY\]\s*(\{[\s\S]*?\})\s*\[\/REVIEW_SUMMARY\]/); if (match) { try { const parsed = JSON.parse(match[1]); if (parsed && typeof parsed.maxSeverity === "string") return parsed; } catch { /* fallthrough */ } } const fallback = output.match(/\{"maxSeverity":\s*"(critical|medium|low)"[\s\S]*?\}/); if (fallback) { try { const parsed = JSON.parse(fallback[0]); if (parsed && typeof parsed.maxSeverity === "string") return parsed; } catch { /* fallthrough */ } } return null; } export function extractSeverityFromText( text: string, ): { maxSeverity: string; critical: number; medium: number; low: number } | null { const headerCritical = [...text.matchAll(/^###\s+C\d+\./gm)].length; const headerMedium = [...text.matchAll(/^###\s+M\d+\./gm)].length; const headerLow = [...text.matchAll(/^###\s+L\d+\./gm)].length; if (headerCritical + headerMedium + headerLow > 0) { return { maxSeverity: headerCritical > 0 ? "critical" : headerMedium > 0 ? "medium" : "low", critical: headerCritical, medium: headerMedium, low: headerLow, }; } const tableCritical = [...text.matchAll(/^\|\s*\w+\s*\|\s*critical/gim)].length; const tableMedium = [...text.matchAll(/^\|\s*\w+\s*\|\s*medium/gim)].length; const tableLow = [...text.matchAll(/^\|\s*\w+\s*\|\s*low/gim)].length; if (tableCritical + tableMedium + tableLow > 0) { return { maxSeverity: tableCritical > 0 ? "critical" : tableMedium > 0 ? "medium" : "low", critical: tableCritical, medium: tableMedium, low: tableLow, }; } const labelCritical = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*critical/gi)].length; const labelMedium = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*medium/gi)].length; const labelLow = [...text.matchAll(/\*\*(?:Severity|严重程度|严重性)\*\*\s*:\s*low/gi)].length; if (labelCritical + labelMedium + labelLow > 0) { return { maxSeverity: labelCritical > 0 ? "critical" : labelMedium > 0 ? "medium" : "low", critical: labelCritical, medium: labelMedium, low: labelLow, }; } return null; } export function readLatestReviewMd(cwd: string): string | null { const reviewDir = path.join(cwd, DEV_OUTPUT_DIR, "pi-review", "md"); try { if (!fs.existsSync(reviewDir)) return null; const files = fs.readdirSync(reviewDir) .filter(f => f.endsWith(".md")) .map(f => ({ name: f, mtime: fs.statSync(path.join(reviewDir, f)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); if (files.length === 0) return null; return fs.readFileSync(path.join(reviewDir, files[0].name), "utf-8"); } catch { return null; } } export function isTimeoutResult(r: SubagentResult): boolean { return r.exitCode === -1 && r.stderr.includes("timed out"); } /** * Extract a human-readable task summary from the prompt. */ export function extractTaskSummary(prompt: string): string { const firstLine = prompt.split("\n").find(l => l.trim()) ?? ""; const tagMatch = firstLine.match(/^\[([^\]]+)\]\s*(.+)/); if (tagMatch) { const tag = tagMatch[1]!.trim(); const rest = tagMatch[2]!.trim(); // If the rest looks like placeholder dots, try to find a better summary if (rest.replace(/\.\.\./g, "").trim() === "" || rest === "...") { const lines = prompt.split("\n").filter(l => l.trim()); if (lines.length > 1) { const secondLine = lines[1]!.replace(/^[*\s#]+/, "").trim(); if (secondLine && !secondLine.startsWith("**")) { return `${tag} - ${secondLine.substring(0, 60)}`; } } for (const line of lines.slice(1, 5)) { const cleaned = line.replace(/^[*\s#]+/, "").trim(); if (cleaned && cleaned.length > 5 && !cleaned.startsWith("**") && !cleaned.startsWith("`")) { const summary = cleaned.length > 50 ? cleaned.substring(0, 47) + "..." : cleaned; return `${tag} - ${summary}`; } } return `${tag} - 工作流任务`; } return `${tag} - ${rest}`; } const cleaned = firstLine.replace(/^[*\s#]+/, "").trim(); return cleaned.length > 60 ? cleaned.substring(0, 57) + "..." : cleaned || "工作流任务"; } // ── Git diff-based file change detection ────────────────────── /** * TypeScript struct for a parsed git file change entry. * `status` uses git's canonical single-letter codes: M (modify), A (add), D (delete). */ interface GitFileChange { status: "M" | "A" | "D"; path: string; } /** * Run `git diff --name-status` to detect file changes against HEAD. * This is far more reliable than scraping AI text output for file paths. * * Uses `git diff --name-status HEAD` for modified/deleted files, and * `git status --porcelain` to catch untracked (new) files. * * Returns an array of { status, path } where status is "M"/"A"/"D" matching * git's own format, ready for direct display in the widget. */ function getGitDiffChanges(cwd: string): GitFileChange[] { const changes: GitFileChange[] = []; const seen = new Set(); try { // 1. `git diff --name-status` — shows modified (M) and deleted (D) vs HEAD // Format: "M\tpath/to/file" (tab-separated) or "M path/to/file" (spaces) const diffOutput = execSync("git diff --name-status", { cwd, encoding: "utf8", timeout: 5000 }).trim(); if (diffOutput) { for (const line of diffOutput.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; // 使用正则解析:支持 tab 分隔("M\tpath")和空格填充("M path")两种格式 const statusMatch = trimmed.match(/^([MAD])\s+(.+)$/); if (statusMatch) { const status = statusMatch[1]!.trim(); const filePath = statusMatch[2]!.trim(); if (filePath && !seen.has(filePath) && (status === "M" || status === "A" || status === "D")) { seen.add(filePath); changes.push({ status: status as "M" | "A" | "D", path: filePath }); } } // 后备:tab split(兼容部分 git 版本输出的 tab 格式) else if (trimmed.includes("\t")) { const parts = trimmed.split("\t"); if (parts.length === 2) { const status = parts[0]!.trim(); const filePath = parts[1]!.trim(); if (filePath && !seen.has(filePath) && (status === "M" || status === "A" || status === "D")) { seen.add(filePath); changes.push({ status: status as "M" | "A" | "D", path: filePath }); } } } } } // 2. `git status --porcelain` — find untracked files (??) missing from git diff // Format: "XY filepath" (e.g., " M .gitignore", "?? newfile.ts", "A filepath") const statusOutput = execSync("git status --porcelain", { cwd, encoding: "utf8", timeout: 5000 }).trim(); if (statusOutput) { for (const line of statusOutput.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; // 使用正则解析 --porcelain 格式:前 2 字符状态码 + 空格 + 路径 const statusMatch2 = trimmed.match(/^(..)\s+(.+)$/); if (statusMatch2) { const statusPrefix = statusMatch2[1]!.trim(); const filePath = statusMatch2[2]!.trim(); if (filePath && !seen.has(filePath) && (statusPrefix === "??" || statusPrefix === "A " || statusPrefix.startsWith("A"))) { seen.add(filePath); changes.push({ status: "A", path: filePath }); } } } } } catch { // Git not available or not a repo — silently skip } return changes; } /** * 检查路径是否为工作流生成的产物路径(.pi-dev-output/), * 若是则不应出现在 A: M: D: 文件变更列表中。 * 该目录已加入 .gitignore,git diff 本身已忽略, * 此函数作为防御性过滤。 */ function isWorkflowArtifactPath(filePath: string): boolean { return filePath.startsWith(".pi-dev-output/"); } /** * Check whether a file's current content hash differs from its baseline. * Returns true if the hash changed or the file is no longer accessible. */ function hasContentChanged(cwd: string, path: string, baselineHash: string): boolean { try { const currentHash = execSync(`git hash-object "${path}"`, { cwd, encoding: 'utf8', timeout: 3000 }).trim(); return currentHash !== baselineHash; } catch { // file deleted or inaccessible — consider changed return true; } } /** * Capture baseline snapshot of all dirty files at workflow start. * Records git object hashes so we can later distinguish pre-existing * changes from workflow-generated ones. */ function captureBaseline(cwd: string): void { _workflowBaseline = []; try { const changes = getGitDiffChanges(cwd); for (const change of changes) { let hash = ""; try { hash = execSync(`git hash-object "${change.path}"`, { cwd, encoding: "utf8", timeout: 3000 }).trim(); } catch { // file might not exist (deleted in diff) } _workflowBaseline.push({ path: change.path, hash }); } } catch { // git not available or not a repo } } /** * Update widget tool list from git diff changes, filtered against the baseline. * Only reports changes that are genuinely new or modified BY the workflow, * not pre-existing dirty files that the workflow didn't touch. * * Uses git-format status codes (M, A, D) for display consistency. * Deduplicates against existing _workflowFileChanges. * * The dedup key includes stepIndex (filePath:stepIndex) so the same file * can appear across different workflow steps (e.g., created by worker in * loop-group, then reviewed by reviewer) without being collapsed into one. * Without :stepIndex, the Set dedup would wrongly skip a file modified in * a later step just because it was already tracked in an earlier step. * */ function updateToolsFromGit(cwd: string, stepIndex: number, agentName: string): void { const currentChanges = getGitDiffChanges(cwd); const seen = new Set(_workflowFileChanges.map(c => `${c.filePath}:${c.stepIndex}`)); for (const change of currentChanges) { if (seen.has(`${change.path}:${stepIndex}`)) continue; // ── Baseline filtering ────────────────────────────── // Skip files that were already dirty at workflow start and haven't been touched. if (_workflowBaseline.length > 0) { const baselineEntry = _workflowBaseline.find(b => b.path === change.path); if (baselineEntry) { if (!hasContentChanged(cwd, change.path, baselineEntry.hash)) { // Pre-existing dirty file, content unchanged — workflow didn't touch it continue; } // Content changed → workflow modified it, report as new change // (Remove old baseline entry so next git diff sees it as workflow-owned) _workflowBaseline = _workflowBaseline.filter(b => b.path !== change.path); } } // Skip files in .pi-dev-output/ — these are workflow-generated artifacts (plans, reviews) // and should NOT be reported as user repo changes. Real git clients (VSCode, Zed) also // ignore this directory (it's in .gitignore). This filter is defense-in-depth. // Note: git always uses forward slashes in paths, even on Windows. if (isWorkflowArtifactPath(change.path)) continue; seen.add(`${change.path}:${stepIndex}`); const type: FileChangeEntry["type"] = change.status === "A" ? "new" : change.status === "D" ? "delete" : "edit"; _workflowFileChanges.push({ agent: agentName, stepIndex, type, filePath: change.path, timestamp: new Date().toISOString(), }); // Use git-style format for display: "M path", "A path", "D path" addWidgetSubStepTool(stepIndex, agentName, `${change.status} ${change.path}`); _widgetExtraToolCount++; } } // ═══════════════════════════════════════════════════════════════ // Checkpoint // ═══════════════════════════════════════════════════════════════ function saveCheckpoint(cwd: string, data: CheckpointData): void { ensureOutputDir(cwd, "pi-workflow"); data.updatedAt = new Date().toISOString(); // Always version 2 (data as CheckpointData).version = 2; // Enrich with file changes and agent history from module state if (_workflowFileChanges.length > 0 && !data.fileChanges) { data.fileChanges = [..._workflowFileChanges]; } if (_workflowAgentRunHistory.length > 0 && !data.agentRunHistory) { data.agentRunHistory = [..._workflowAgentRunHistory]; } fs.writeFileSync(path.join(cwd, CHECKPOINT_FILE), JSON.stringify(data, null, 2), "utf-8"); } export function loadCheckpointFromFile(cwd: string): CheckpointData | null { try { const content = fs.readFileSync(path.join(cwd, CHECKPOINT_FILE), "utf-8"); const data = JSON.parse(content) as CheckpointData; // Backfill missing fields for v1 checkpoints if (!data.version || data.version < 2) { data.version = 2; data.fileChanges = data.fileChanges ?? []; data.agentRunHistory = data.agentRunHistory ?? []; } return data; } catch { return null; } } /** * Archive checkpoint after completion: rename to checkpoint--.json */ export function archiveCheckpointFile(cwd: string, planFileRelPath?: string): void { try { const cpPath = path.join(cwd, CHECKPOINT_FILE); if (!fs.existsSync(cpPath)) return; const uuidPart = _workflowId ? `-${_workflowId}` : ""; const planId = planFileRelPath ? path.basename(planFileRelPath, ".md").replace(/[^a-zA-Z0-9_-]/g, "_") : `archive-${Date.now().toString(36)}`; const archiveName = `checkpoint${uuidPart}-${planId}.json`; const archiveDir = path.join(cwd, DEV_OUTPUT_DIR, "pi-workflow"); fs.renameSync(cpPath, path.join(archiveDir, archiveName)); } catch { /* ignore */ } } // ═══════════════════════════════════════════════════════════════ // Task builders // ═══════════════════════════════════════════════════════════════ function buildTaskForStep( agentName: string, prompt: string, planFileRelPath: string | undefined, cwd: string, workflowId: string, chainContext?: string, ): string { if (agentName === "planner") { return [ "请根据以下功能需求,分析代码库结构,生成详细的实施计划,并写入 .pi-dev-output/pi-plans/ 目录。", "", "## 文件名格式", `-<简短功能名>-${workflowId}.md`, "请在文件名末尾添加工作流 UUID,以便同一工作流的所有文件可关联追溯。", "", "## 功能需求", prompt, ...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []), ].join("\n"); } if (agentName === "worker") { const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined; const planLocation = planFileRelPath ? `\n实施计划文件位于: ${planFileRelPath}\n(可在 .pi-dev-output/pi-plans/ 目录中 grep UUID "${workflowId}" 找到)` : ""; if (planContent) { return [ "请根据以下实施计划逐步实现代码改动。", "", "## 实施计划", planContent, planLocation, "", "## 原始需求与修改反馈", prompt, ...(chainContext ? ["", chainContext] : []), "", "请严格按照计划中的步骤实施,不要做计划外的修改。", "", "实施完成后,在输出中列出你修改的所有文件,供后续审查者参考。", ...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []), ].join("\n"); } return [ "请根据以下功能需求实施代码改动。", "", "## 功能需求", prompt, planLocation, ...(chainContext ? ["", chainContext] : []), "", "请先分析代码库,制定简要计划,再逐步实施。", "实施完成后,在输出中列出你修改的所有文件,供后续审查者参考。", ...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []), ].join("\n"); } if (agentName === "trimmer") { const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined; return [ "请精简当前代码库的代码。", "缩短不必要的冗长行,优化可读性,消除可合并的重复逻辑。", "注意:以下实施计划列出了本次工作流的核心新增内容,精简时请确保不影响这些改动。", "", "## 原始功能需求", prompt, ...(planContent ? ["", "## 实施计划(改动范围)", planContent] : []), ...(chainContext ? ["", chainContext] : []), ...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []), ].join("\n"); } if (agentName === "docWriter") { const planContent = planFileRelPath ? `\n\n## 实施计划\n${readFileContent(cwd, planFileRelPath) ?? ""}` : ""; return [ "请根据当前代码状态,更新 README.md 文档,必要时添加关键代码注释。", "", "## 功能需求", prompt, planContent, ...(chainContext ? ["", chainContext] : []), ...(buildWorkflowInfoBlock() ? ["", buildWorkflowInfoBlock()] : []), ].join("\n"); } return prompt; } function buildReviewTask( prompt: string, planFileRelPath: string | undefined, cwd: string, workflowId: string, chainContext?: string, ): string { const planContent = planFileRelPath ? readFileContent(cwd, planFileRelPath) : undefined; const parts = [ "请审查当前代码库中针对以下功能的实现。", "检查是否有 bug、逻辑错误、未完成的功能、代码质量问题。", "将详细审查报告写入 .pi-dev-output/pi-review/md/ 目录。", "", "## 文件名格式", `review--${workflowId}.md`, "请在文件名末尾添加工作流 UUID,以便同一工作流的所有文件可关联追溯。", "", "在回复末尾输出以下格式的结构化摘要(必须包含):", "[REVIEW_SUMMARY]", '{"maxSeverity":"critical|medium|low","critical":N,"medium":N,"low":N}', "[/REVIEW_SUMMARY]", "", "⚠️ 重要:这个 JSON 摘要不是可选的,而是强制性的。", "如果缺少此 JSON 摘要,工作流将无法判断是否需要继续修复循环。", "请在回复的末尾单独输出,确保前后无其他文本(除换行符外)。", "注意最大严重级别字段名是 maxSeverity(注意大小写)。", "", "## 功能需求", prompt, ]; if (planContent) parts.push("", "## 实施计划", planContent); if (chainContext) parts.push("", chainContext); if (buildWorkflowInfoBlock()) parts.push("", buildWorkflowInfoBlock()); return parts.join("\n"); } // ═══════════════════════════════════════════════════════════════ // Global state for async execution // ═══════════════════════════════════════════════════════════════ interface StepRuntimeInfo { widgetStep: WorkflowStepWidgetState; state: WorkflowStepState; } let _workflowAbortController: AbortController | null = null; let _workflowPi: ExtensionAPI | null = null; let _workflowType: string | undefined; let _workflowCwd = ""; let _workflowPrompt = ""; let _workflowPlanFileRelPath: string | undefined; /** Track loop counts at module level so cancel callback can save a proper checkpoint. */ let _workflowLoopCounts: Record = {}; /** Original checkpoint creation timestamp, for preserving across cancel. */ let _workflowCreatedAt: string = new Date().toISOString(); /** Track file changes globally for checkpoint persistence */ let _workflowFileChanges: FileChangeEntry[] = []; /** Track agent run history */ let _workflowAgentRunHistory: AgentRunEntry[] = []; /** Store step defs for pre-populating sub-steps */ let _workflowStepDefs: WorkflowStepDef[] = []; /** Baseline snapshot: git hashes of dirty files at workflow start */ let _workflowBaseline: BaselineEntry[] = []; /** Workflow UUID for cross-file traceability */ let _workflowId = ""; /** Chain context: keyed parts of previous agent output to pass to next agent */ let _chainContextParts: Record = {}; let _widgetMode: WorkflowMode = "attended"; let _widgetSteps: WorkflowStepWidgetState[] = []; let _widgetCurrentIdx = 0; let _widgetStartTime = 0; let _widgetExtraToolCount = 0; let _widgetExtraTokenCount = 0; let _workflowRunning = false; let _cleanupTimer: ReturnType | null = null; // ── Chain context helpers ────────────────────────────────────── /** * Update a named part of the chain context. Each key is a section header; * calling with the same key overwrites the previous value. */ function updateChainContext(key: string, content: string): void { if (content) { _chainContextParts[key] = content; } else { delete _chainContextParts[key]; } } /** * Build the chain context string from all parts, for injection into * buildTaskForStep / buildReviewTask prompts. */ function buildChainContext(): string { const parts = Object.entries(_chainContextParts) .filter(([_, v]) => v) .map(([k, v]) => `## ${k}\n${v}`); return parts.length > 0 ? parts.join("\n\n") : ""; } /** * Reset chain context — called at the start of a new workflow. */ function resetChainContext(): void { _chainContextParts = {}; } /** * Build a standard Workflow Info block injected into every subagent prompt. */ function buildWorkflowInfoBlock(): string { if (!_workflowId) return ""; const modeLabel = _widgetMode === "full-auto" ? "全自动" : _widgetMode === "full-attended" ? "完全值守" : "值守"; return [ "## 工作流信息", `- 工作流 UUID: ${_workflowId}`, `- 工作流类型: ${_workflowType ?? "通用"}`, `- 工作流启动时间: ${_workflowCreatedAt}`, `- 工作流模式: ${modeLabel}`, "", "提示:如果需要在 .pi-dev-output/ 下查找属于本工作流的文件,", `请在工作流输出目录中搜索工作流 UUID "${_workflowId}"。`, ].join("\n"); } function refreshWidget(): void { if (!_lastWorkflowCtx) return; const taskSummary = extractTaskSummary(_workflowPrompt); const widgetState = buildWidgetState( _widgetMode, _widgetSteps, _widgetCurrentIdx, _widgetStartTime, _workflowRunning ? "running" : _widgetSteps.some(s => s.status === "failed") ? "failed" : _widgetSteps.every(s => s.status === "done" || s.status === "skipped") ? "done" : "running", { toolCount: _widgetExtraToolCount, tokenCount: _widgetExtraTokenCount }, taskSummary, _workflowId, ); updateWorkflowWidget(_lastWorkflowCtx, widgetState); } let _lastWorkflowCtx: ExtensionCommandContext | null = null; function initWidget(ctx: ExtensionCommandContext, mode: WorkflowMode, stepsCount: number): void { _widgetMode = mode; _widgetSteps = []; for (let i = 0; i < stepsCount; i++) { _widgetSteps.push({ label: "", status: "pending" }); } _widgetCurrentIdx = 0; _widgetStartTime = Date.now(); _widgetExtraToolCount = 0; _widgetExtraTokenCount = 0; if (_cleanupTimer) { clearTimeout(_cleanupTimer); _cleanupTimer = null; } _lastWorkflowCtx = ctx; _workflowRunning = true; refreshWidget(); } function updateWidgetStep( index: number, label: string, status: WorkflowStepWidgetState["status"], extra?: { durationMs?: number; loopCount?: number; maxLoops?: number; timeoutMs?: number; error?: string; subSteps?: WorkflowSubStepWidgetState[]; startedAt?: number; }, ): void { if (index < _widgetSteps.length) { const existing = _widgetSteps[index]; _widgetSteps[index] = { ...existing, label, status, ...extra, }; } refreshWidget(); } function populatePredefinedSubSteps(stepIndex: number): void { const step = _widgetSteps[stepIndex]; if (!step || !_workflowStepDefs[stepIndex]) return; if (step.subSteps && step.subSteps.length > 0) return; // already populated const def = _workflowStepDefs[stepIndex]!; const agents = discoverAgents(); function agentThinkingLevel(name: string): string | undefined { return agents.find(a => a.name === name)?.thinkingLevel; } const newSubSteps: WorkflowSubStepWidgetState[] = []; if (def.type === "loop-group") { if (def.loopAgentName) { newSubSteps.push({ agent: def.loopAgentName, thinkingLevel: agentThinkingLevel(def.loopAgentName), status: "pending", tools: [], outputs: [], }); } if (def.reviewAgentName) { newSubSteps.push({ agent: def.reviewAgentName, thinkingLevel: agentThinkingLevel(def.reviewAgentName), status: "pending", tools: [], outputs: [], }); } } else if (def.agentName) { newSubSteps.push({ agent: def.agentName, thinkingLevel: agentThinkingLevel(def.agentName), status: "pending", tools: [], outputs: [], }); } if (newSubSteps.length > 0) { step.subSteps = newSubSteps; refreshWidget(); } } function addWidgetSubStepTool(stepIndex: number, agentName: string, tool: string): void { const step = _widgetSteps[stepIndex]; if (!step) return; const sub = step.subSteps?.find(s => s.agent === agentName); if (sub) { if (!sub.tools) sub.tools = []; sub.tools.push(tool); if (sub.tools.length > 20) sub.tools = sub.tools.slice(-20); // keep last 20 refreshWidget(); } } function addWidgetSubStepOutput(stepIndex: number, agentName: string, output: string): void { const step = _widgetSteps[stepIndex]; if (!step) return; const sub = step.subSteps?.find(s => s.agent === agentName); if (sub) { if (!sub.outputs) sub.outputs = []; if (!sub.outputs.includes(output)) { sub.outputs.push(output); } refreshWidget(); } } function setWidgetSubStepStatus(stepIndex: number, agentName: string, status: WorkflowSubStepWidgetState["status"]): void { const step = _widgetSteps[stepIndex]; if (!step) return; const sub = step.subSteps?.find(s => s.agent === agentName); if (sub) { sub.status = status; refreshWidget(); } } /** * 重置子步骤为 pending 状态并清除计时信息(durationMs/startedAt)。 * 用于循环组中开启新循环时,清除上一轮的计时数据。 */ function resetWidgetSubStepTimers(stepIndex: number, agentName: string): void { const step = _widgetSteps[stepIndex]; if (!step) return; const sub = step.subSteps?.find(s => s.agent === agentName); if (sub) { sub.status = "pending"; sub.durationMs = undefined; sub.startedAt = undefined; sub.tools = []; sub.outputs = []; } refreshWidget(); } function setWidgetCurrentStep(index: number): void { _widgetCurrentIdx = index; refreshWidget(); } function cleanupWidget(): void { if (_cleanupTimer) { clearTimeout(_cleanupTimer); _cleanupTimer = null; } _workflowRunning = false; if (_lastWorkflowCtx) { updateWorkflowWidget(_lastWorkflowCtx, null); _lastWorkflowCtx = null; } _workflowAbortController = null; setWorkflowCancelCallback(null); // Clean up terminal input listener (Esc) if (_terminalInputUnsubscribe) { _terminalInputUnsubscribe(); _terminalInputUnsubscribe = null; } // Clean up signal handlers cleanupSignalHandlers(); } /** Unsubscribe function for terminal input listener (Esc to cancel) */ let _terminalInputUnsubscribe: (() => void) | null = null; // ── Signal handling (SIGINT/SIGTERM) for graceful workflow cancellation ── let _signalHandlersRegistered = false; function cleanupSignalHandlers(): void { if (!_signalHandlersRegistered) return; try { process.removeListener("SIGINT", onSigint); } catch { /* ignore */ } try { process.removeListener("SIGTERM", onSigterm); } catch { /* ignore */ } _signalHandlersRegistered = false; } function onSigint(): void { if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) { console.log("\n[workflow] SIGINT received, cancelling workflow..."); cancelWorkflow(); } } function onSigterm(): void { if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) { cancelWorkflow(); } } function registerSignalHandlers(): void { if (_signalHandlersRegistered) return; try { process.on("SIGINT", onSigint); process.on("SIGTERM", onSigterm); _signalHandlersRegistered = true; } catch { /* ignore */ } } // ── Cancel handler ── // ═══════════════════════════════════════════════════════════════ // Step change rollback (for "back" navigation) // ═══════════════════════════════════════════════════════════════ /** * Revert file changes made by a specific step index using git. * This is called when the user selects "back" during step confirmation. * * - "edit" files: git checkout HEAD to restore to committed state * - "new" files: delete the untracked file * - "delete" files: git checkout HEAD to restore the deleted file */ function revertStepChanges(stepIndex: number): void { const changesForStep = _workflowFileChanges.filter(c => c.stepIndex === stepIndex); if (changesForStep.length === 0) return; for (const change of changesForStep) { try { const fullPath = path.join(_workflowCwd, change.filePath); switch (change.type) { case "edit": case "delete": // Restore from git HEAD execSync(`git checkout HEAD -- "${change.filePath}"`, { cwd: _workflowCwd, encoding: "utf8", timeout: 5000, timeoutKill: 1000, }); break; case "new": // Delete the newly created file try { if (fs.existsSync(fullPath)) { fs.rmSync(fullPath, { force: true }); } } catch { /* ignore if file doesn't exist */ } break; } } catch { /* if git fails, skip this file */ } } // Remove the reverted changes from the tracking list _workflowFileChanges = _workflowFileChanges.filter(c => c.stepIndex !== stepIndex); } // ═══════════════════════════════════════════════════════════════ // Agent runner (non-blocking, widget-based) // ═══════════════════════════════════════════════════════════════ /** * Run a sub-agent without blocking the main TUI. * Progress is reported via the widget sub-step system. * Uses the global AbortController for cancellation. */ async function runAgentWithProgress( agent: AgentDef, task: string, stepIndex: number, agentName: string, timeoutMs: number, ): Promise { const signal = _workflowAbortController?.signal; const agentStartTime = Date.now(); // Initialize sub-step in widget const step = _widgetSteps[stepIndex]; if (step) { if (!step.subSteps) step.subSteps = []; const existing = step.subSteps.find(s => s.agent === agentName); if (!existing) { step.subSteps.push({ agent: agentName, status: "running", tools: [], outputs: [], startedAt: agentStartTime, detail: `超时时间${formatTimeout(timeoutMs)}`, }); refreshWidget(); } else { // Update existing sub-step status, startedAt, and detail existing.status = "running"; existing.startedAt = agentStartTime; existing.detail = `超时时间${formatTimeout(timeoutMs)}`; refreshWidget(); } } // ── Start git diff polling for real file changes ── // This replaces the unreliable regex-based tool call sniffing that was removed. // git diff --name-status is the same approach used by VSCode, Zed, and every // professional git client — it's deterministic, noise-free, and 100% accurate. let _gitPollTimer: ReturnType | null = null; const _pollSeen = new Set(); if (_workflowCwd) { _gitPollTimer = setInterval(() => { try { const currentChanges = getGitDiffChanges(_workflowCwd); for (const change of currentChanges) { const key = `${change.path}:${stepIndex}`; if (_pollSeen.has(key)) continue; _pollSeen.add(key); if (isWorkflowArtifactPath(change.path)) continue; if (_workflowBaseline.length > 0) { const baseEntry = _workflowBaseline.find(b => b.path === change.path); if (baseEntry) { if (!hasContentChanged(_workflowCwd, change.path, baseEntry.hash)) continue; // ⭐ 同步删除已变更的基线条目,与 updateToolsFromGit 行为一致 _workflowBaseline = _workflowBaseline.filter(b => b.path !== change.path); } } // ⭐ Also record in _workflowFileChanges so the completion report file tree // and revertStepChanges have accurate data. The final updateToolsFromGit call // will skip this via seen, so we must record it here. const type: FileChangeEntry["type"] = change.status === "A" ? "new" : change.status === "D" ? "delete" : "edit"; _workflowFileChanges.push({ agent: agentName, stepIndex, type, filePath: change.path, timestamp: new Date().toISOString(), }); addWidgetSubStepTool(stepIndex, agentName, `${change.status} ${change.path}`); _widgetExtraToolCount++; } } catch { /* 静默忽略 git 错误 */ } }, 5000); _gitPollTimer.unref(); } // Run the sub-agent with progress reporting // The toolMatch regex sniffing has been removed — it was the primary source of // false positives in the A: M: D: panel (matching AI natural language as tool calls). // File change detection is now handled entirely by git diff polling (above) and // the final updateToolsFromGit call (below), both of which use git's own API. let result; try { result = await spawnSubagent(agent, task, _workflowCwd, signal, timeoutMs, (progress) => { // Detect output file paths — only match .pi-dev-output paths belonging to current workflow if (_workflowId) { const outputMatch = progress.match(/\.pi-dev-output\/[a-zA-Z0-9_\/\.-]+/i); if (outputMatch) { const pathCandidate = outputMatch[0]!.trim(); // ⭐ 严格白名单:拒绝中文、引号、括号等非路径字符 if (pathCandidate.length > 15 && pathCandidate.length < 300 && pathCandidate.includes(_workflowId) && /^[\w.\/-]+$/.test(pathCandidate)) { addWidgetSubStepOutput(stepIndex, agentName, pathCandidate); } } } }, _workflowId ? { workflowId: _workflowId } : undefined); } finally { if (_gitPollTimer) { clearInterval(_gitPollTimer); _gitPollTimer = null; } } const agentDuration = Date.now() - agentStartTime; // Record agent run in history _workflowAgentRunHistory.push({ agent: agentName, stepIndex, startedAt: new Date(agentStartTime).toISOString(), durationMs: agentDuration, exitCode: result.exitCode, toolCount: _widgetExtraToolCount, }); // ── Post-completion: file change detection via git diff ONLY ── // We do NOT parse agent output text for file paths — that approach is fragile and // produces false positives (e.g., matching JS property names like `.push`, `.length` // from code snippets). Instead, we rely entirely on git diff --name-status, which is // the same approach used by VSCode, Zed, and every other professional git client. // It's deterministic, noise-free, and always produces correct relative paths. const finalOutput = extractFinalOutput(result.output) || result.output; // Find output file paths (plans, reviews) for display in the "output:" section // These are workflow artifacts (.pi-dev-output/) — only show files belonging to // the CURRENT workflow to avoid cross-contamination from previous runs. // Note: file change detection itself relies exclusively on git diff below, not text parsing. // ⭐ IMPORTANT: Only search in CLEAN extracted text (finalOutput), NOT raw JSON output. // The old approach concatenated allOutput (raw JSON + stderr) into searchText and // matched against it, producing false positives from JSON stringified content. // By restricting to extractFinalOutput's clean text, we avoid matching against // JSON-escaped paths, code snippets, or natural language in raw agent output. const cleanText = finalOutput || result.output || ""; const seenOutputs = new Set(); if (_workflowId) { // Only match .pi-dev-output paths containing the current workflow UUID const escapedId = _workflowId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const outputPattern = new RegExp(`\\.pi-dev-output\\/[a-zA-Z0-9_\\/.-]*${escapedId}[a-zA-Z0-9_\\/.-]*`, 'g'); let m; while ((m = outputPattern.exec(cleanText)) !== null) { const path_ = m[0]!.trim(); if (path_.length > 15 && path_.length < 300 && !seenOutputs.has(path_)) { // ⭐ 严格白名单:仅允许 [a-zA-Z0-9_./-] 字符 // 拒绝包含中文、引号、括号、空格等非路径字符的乱码匹配 if (!/^[\w.\/-]+$/.test(path_)) continue; seenOutputs.add(path_); addWidgetSubStepOutput(stepIndex, agentName, path_); } } } else { // No workflow ID (unlikely): match .pi-dev-output paths conservatively const outputPattern = /\.pi-dev-output\/[a-zA-Z0-9_\/-]+\.[a-zA-Z0-9]+/g; let m; while ((m = outputPattern.exec(cleanText)) !== null) { const path_ = m[0]!.trim(); if (path_.length > 15 && path_.length < 300 && !seenOutputs.has(path_)) { // ⭐ 严格白名单 if (!/^[\w.\/-]+$/.test(path_)) continue; seenOutputs.add(path_); addWidgetSubStepOutput(stepIndex, agentName, path_); } } } // ── Update file changes from git diff (more accurate than text scraping) ── updateToolsFromGit(_workflowCwd, stepIndex, agentName); // Update sub-step status based on result const subStatus: WorkflowSubStepWidgetState["status"] = result.exitCode === 0 ? "done" : isTimeoutResult(result) ? "failed" : result.exitCode !== 0 ? "failed" : "done"; setWidgetSubStepStatus(stepIndex, agentName, subStatus); // ⭐ 修复:代理完成后设置最终持续时长并清除 startedAt // 确保 UI 使用记录的 durationMs 而非实时 Date.now() - startedAt const completedSub = _widgetSteps[stepIndex]?.subSteps?.find(s => s.agent === agentName); if (completedSub) { completedSub.durationMs = agentDuration; completedSub.startedAt = undefined; } return result; } // ═══════════════════════════════════════════════════════════════ // Single-step executor // ═══════════════════════════════════════════════════════════════ async function executeSingleStep( ctx: ExtensionCommandContext, step: WorkflowStepDef, state: WorkflowStepState, agentMap: Map, prompt: string, planFileRelPath: string | undefined, mode: WorkflowMode, stepIndex: number, ): Promise { const agentName = step.agentName!; const agent = agentMap.get(agentName); if (!agent) throw new Error(`未找到 agent: ${agentName}`); const task = buildTaskForStep(agentName, prompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext()); let retried = false; let result = await runAgentWithProgress(agent, task, stepIndex, agentName, step.timeoutMs); // Timeout handling if (isTimeoutResult(result)) { if (mode === "full-auto" && !retried) { result = await runAgentWithProgress(agent, `[RETRY]\n\n${task}`, stepIndex, agentName, step.timeoutMs); retried = true; } else { const choice = await uiSelect(ctx, `⏰ ${step.label} 执行超时`, [ "1. 重新执行", "2. 跳过此步骤", "3. 取消工作流", ]); if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; } if (choice.startsWith("2")) { state.status = "skipped"; return; } result = await runAgentWithProgress(agent, `[RETRY]\n\n${task}`, stepIndex, agentName, step.timeoutMs); } } if (isTimeoutResult(result)) { throw new Error(`执行超时 (${(step.timeoutMs / 1000).toFixed(0)}s)`); } if (result.exitCode !== 0 && result.stderr) { throw new Error(`Agent 错误 (exit ${result.exitCode}): ${result.stderr.slice(0, 500)}`); } // ── Capture AI work summary as supplementary chain context ── // We intentionally do NOT create chain contexts from _workflowFileChanges here. // File-change-based chain contexts ("计划制定摘要"/"文档更新摘要") were removed in v0.6.0 // because their M/A/D counts provided negligible value for downstream agents compared // to the agent's own free-text work summary. The file-change approach also created // tight coupling between executeSingleStep and the _workflowFileChanges data structure. const workSummary = extractFinalOutput(result.output); if (workSummary) { updateChainContext(`${agentName} 工作总结`, `${agentName} 已完成工作,以下是其工作总结:\n\n${workSummary}`); } else if (result.output) { // 保底:使用输出前 200 字符作为简略摘要 const fallback = result.output.slice(0, 200).trim(); if (fallback) updateChainContext(`${agentName} 工作总结`, fallback); } } // ═══════════════════════════════════════════════════════════════ // Loop-group executor // ═══════════════════════════════════════════════════════════════ async function executeLoopGroup( ctx: ExtensionCommandContext, step: WorkflowStepDef, state: WorkflowStepState, agentMap: Map, prompt: string, planFileRelPath: string | undefined, mode: WorkflowMode, loopCounts: Record, stepIndex: number, ): Promise { const loopAgent = agentMap.get(step.loopAgentName!); const reviewAgent = agentMap.get(step.reviewAgentName!); if (!loopAgent) throw new Error(`未找到 loop agent: ${step.loopAgentName}`); if (!reviewAgent) throw new Error(`未找到 review agent: ${step.reviewAgentName}`); const maxLoops = step.maxLoops ?? 3; let loopCount = loopCounts[step.id] ?? 0; let contextPrompt = prompt; const reviewTimeoutMs = step.reviewTimeoutMs ?? step.timeoutMs; while (loopCount < maxLoops) { loopCount++; // 立即更新 UI 显示当前循环次数 state.loopCount = loopCount; updateWidgetStep(stepIndex, step.label, "running", { loopCount, maxLoops: step.maxLoops, startedAt: _widgetSteps[stepIndex]?.startedAt || Date.now(), }); // 每次循环开始时重置 sub-step 状态(清除上一轮的计时和工具记录) resetWidgetSubStepTimers(stepIndex, step.loopAgentName!); resetWidgetSubStepTimers(stepIndex, step.reviewAgentName!); const loopStartTime = Date.now(); // ── Run loop agent (worker / trimmer) ── const loopTask = buildTaskForStep(step.loopAgentName!, contextPrompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext()); let agentResult = await runAgentWithProgress(loopAgent, loopTask, stepIndex, step.loopAgentName!, step.timeoutMs); // 检查 agent 是否异常退出(非超时非零退出码) while (agentResult.exitCode !== 0 && !isTimeoutResult(agentResult)) { if (mode === "full-auto") { throw new Error(`Agent ${step.loopAgentName} 异常退出 (exit ${agentResult.exitCode}): ${agentResult.stderr.slice(0, 200)}`); } else { const choice = await uiSelect(ctx, `❌ ${step.loopAgentName} 异常退出 (exit ${agentResult.exitCode})`, [ "1. 重新执行", "2. 跳过此步骤", "3. 取消工作流", ]); if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; } if (choice.startsWith("2")) { state.status = "skipped"; return; } // 重新执行 agentResult = await runAgentWithProgress(loopAgent, `[RETRY]\n\n${loopTask}`, stepIndex, step.loopAgentName!, step.timeoutMs); } } // ── Capture loop agent work summary as supplementary chain context ── const loopFinalText = extractFinalOutput(agentResult.output); if (loopFinalText) { updateChainContext(`${step.loopAgentName} 工作总结`, `${step.loopAgentName} 已完成工作,以下是其工作总结:\n\n${loopFinalText}` ); } if (isTimeoutResult(agentResult)) { if (mode === "full-auto") { contextPrompt = `[TIMEOUT_WARNING] 上一个 ${step.loopAgentName} 执行超时。\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext())}`; } else { const choice = await uiSelect(ctx, `⏰ ${step.loopAgentName} 执行超时`, [ "1. 重新执行", "2. 进入审查阶段", "3. 跳过此步骤", "4. 取消工作流", ]); if (!choice || choice.startsWith("4")) { cancelWorkflow(); return; } if (choice.startsWith("3")) { state.status = "skipped"; return; } if (choice.startsWith("2")) { contextPrompt = `[TIMEOUT_WARNING]\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext())}`; } else { agentResult = await runAgentWithProgress(loopAgent, `[RETRY]\n\n${loopTask}`, stepIndex, step.loopAgentName!, step.timeoutMs); if (isTimeoutResult(agentResult)) { contextPrompt = `[TIMEOUT_WARNING]\n\n${buildReviewTask(prompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext())}`; } } } } // ── Run reviewer ── const reviewTask = contextPrompt.includes("[TIMEOUT_WARNING]") ? contextPrompt : buildReviewTask(contextPrompt, planFileRelPath, _workflowCwd, _workflowId, buildChainContext()); const reviewResult = await runAgentWithProgress(reviewAgent, reviewTask, stepIndex, step.reviewAgentName!, reviewTimeoutMs); const extractedOutput = extractFinalOutput(reviewResult.output) || reviewResult.output; const combinedOutput = extractedOutput + "\n" + reviewResult.stderr; let reviewSummary = parseReviewerOutput(combinedOutput); if (!reviewSummary) reviewSummary = extractSeverityFromText(extractedOutput); if (!reviewSummary) { const reviewContent = readLatestReviewMd(_workflowCwd); if (reviewContent) { reviewSummary = parseReviewerOutput(reviewContent) ?? extractSeverityFromText(reviewContent); } } // ── Capture reviewer work summary as supplementary chain context ── // The old review-chain-context entries ("代码审查反馈"/"精简审查反馈") based on // _workflowFileChanges were removed in v0.6.0 because they duplicated information // from the review report file and polluted chain context with file-path-heavy data. // Reuse already-parsed extractedOutput to avoid double parsing. if (extractedOutput) { updateChainContext(`审查工作总结`, `审查者已完成审查,以下是其审查总结:\n\n${extractedOutput}` ); } // ── Decide whether to loop ── if (reviewSummary?.maxSeverity === "critical" && loopCount < maxLoops) { const reviewReportHint = `\n完整审查报告在 .pi-dev-output/pi-review/md/(grep UUID "${_workflowId}" 查找)。`; if (mode === "full-auto") { contextPrompt = [prompt, "", "## 上次审查发现的问题", `审查摘要: ${JSON.stringify(reviewSummary)}`, `请修复 ${reviewSummary.critical} 个严重问题后重新运行。`, reviewReportHint, ].join("\n"); continue; } else { const shouldLoop = await uiConfirm(ctx, "🔄 检测到严重问题", `审查发现 ${reviewSummary.critical} 个严重问题。是否进入下一轮循环 (${loopCount}/${maxLoops})?`); if (shouldLoop) { contextPrompt = [prompt, "", "## 上次审查发现的问题", `审查摘要: ${JSON.stringify(reviewSummary)}`, `请修复这些严重问题后重新运行。`, reviewReportHint, ].join("\n"); continue; } break; } } break; } state.loopCount = loopCount; loopCounts[step.id] = loopCount; } // ═══════════════════════════════════════════════════════════════ // Main async workflow executor // ═══════════════════════════════════════════════════════════════ async function executeWorkflowBackground( ctx: ExtensionCommandContext, pi: ExtensionAPI, prompt: string, steps: WorkflowStepDef[], agentMap: Map, mode: WorkflowMode, stepStates: WorkflowStepState[], initialStepIndex: number, initialLoopCounts: Record, planFileRelPath: string | undefined, existingCp: CheckpointData | undefined, ): Promise { let loopCounts = { ...initialLoopCounts }; let currentStepIndex = initialStepIndex; let planFileRelPathInner = planFileRelPath; for (; currentStepIndex < steps.length; currentStepIndex++) { // Check abort if (_workflowAbortController?.signal.aborted) { return; } const step = steps[currentStepIndex]!; const state = stepStates[currentStepIndex]!; if (state.status === "done" || state.status === "skipped") continue; setWidgetCurrentStep(currentStepIndex); // Pre-populate sub-steps for pending steps so UI shows queued agents populatePredefinedSubSteps(currentStepIndex); // ── Helper: handle "back" from any confirmation dialog ── // Returns true if back was handled (caller should continue without execution). const handleBack = async (): Promise => { if (currentStepIndex > 0) { // Revert file changes made by the previous step revertStepChanges(currentStepIndex - 1); // Reset previous step state stepStates[currentStepIndex - 1]!.status = "pending"; stepStates[currentStepIndex - 1]!.durationMs = undefined; stepStates[currentStepIndex - 1]!.error = undefined; stepStates[currentStepIndex - 1]!.loopCount = undefined; updateWidgetStep( currentStepIndex - 1, steps[currentStepIndex - 1]!.label, "pending", ); // Clear sub-steps for the reverted step if (_widgetSteps[currentStepIndex - 1]) { _widgetSteps[currentStepIndex - 1]!.subSteps = []; } currentStepIndex -= 2; // loop will ++, landing on the previous step saveCheckpoint(_workflowCwd, buildCp()); return true; } return false; }; // ── User confirmations (BEFORE timer starts) ── // Confirmation for [confirm] steps (attended mode) if (step.type === "confirm" && mode !== "full-auto") { const items = [ "1. 进入此步骤", "2. 自定义输入", "3. 跳过此步骤", "4. 取消工作流", ]; if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT); const choice = await uiSelect(ctx, `📌 ${step.label}`, items); if (choice === BACK_OPTION_TEXT) { if (await handleBack()) continue; return; // can't go back from first step } if (!choice || choice.startsWith("4")) { cancelWorkflow(); return; } if (choice.startsWith("3")) { state.status = "skipped"; saveCheckpoint(_workflowCwd, buildCp()); updateWidgetStep(currentStepIndex, step.label, "skipped"); continue; } if (choice.startsWith("2")) { const customInput = await uiInput(ctx, "✏️ 自定义输入", "输入你的指令或反馈"); if (customInput !== undefined && customInput.trim()) { prompt = `${prompt}\n\n## 用户自定义指令\n${customInput.trim()}`; } } } // Full-attended: confirm every step if (mode === "full-attended" && step.type !== "confirm") { const items = ["1. 执行", "2. 跳过", "3. 取消工作流"]; if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT); const choice = await uiSelect(ctx, `📌 ${step.label} — 执行?`, items); if (choice === BACK_OPTION_TEXT) { if (await handleBack()) continue; return; } if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; } if (choice.startsWith("2")) { state.status = "skipped"; saveCheckpoint(_workflowCwd, buildCp()); updateWidgetStep(currentStepIndex, step.label, "skipped"); continue; } } // Attended: confirm loop-group steps (e.g. 实施代码 → 审查) if (step.type === "loop-group" && mode === "attended") { const items = [ "1. 进入此步骤", "2. 跳过此步骤", "3. 取消工作流", ]; if (currentStepIndex > 0) items.push(BACK_OPTION_TEXT); const choice = await uiSelect(ctx, `📌 ${step.label}`, items); if (choice === BACK_OPTION_TEXT) { if (await handleBack()) continue; return; } if (!choice || choice.startsWith("3")) { cancelWorkflow(); return; } if (choice.startsWith("2")) { state.status = "skipped"; saveCheckpoint(_workflowCwd, buildCp()); updateWidgetStep(currentStepIndex, step.label, "skipped"); continue; } } // ── Before executing docWriter, rebuild chain context with only relevant info ── if (step.agentName === "docWriter") { // Clear stale context (review feedback from worker/trimmer loops is noise for docWriter) resetChainContext(); // Restore plan location if (planFileRelPathInner) { updateChainContext("实施计划", `计划文件: ${planFileRelPathInner}\n` + `(可在 .pi-dev-output/pi-plans/ 中 grep UUID ${_workflowId} 找到)` ); } // Build comprehensive change summary from ALL file changes const workerChanges = _workflowFileChanges .filter(c => c.type !== "read" && c.agent === "worker") .map(c => `${c.type === "new" ? "A" : c.type === "delete" ? "D" : "M"} ${c.filePath}`); const trimmerChanges = _workflowFileChanges .filter(c => c.type !== "read" && c.agent === "trimmer") .map(c => `${c.type === "new" ? "A" : c.type === "delete" ? "D" : "M"} ${c.filePath}`); const allChangeLines: string[] = []; if (workerChanges.length > 0) { allChangeLines.push(`🔧 Worker (代码实施) - ${workerChanges.length} 个文件:`); allChangeLines.push(...workerChanges); } if (trimmerChanges.length > 0) { allChangeLines.push(`✂️ Trimmer (代码精简) - ${trimmerChanges.length} 个文件:`); allChangeLines.push(...trimmerChanges); } if (allChangeLines.length > 0) { updateChainContext("本次工作流全部变更", `以下文件已被本次工作流修改:\n${allChangeLines.join("\n")}` ); } } // ── Execute (timer starts NOW, after all user confirmations) ── state.status = "running"; const stepStartTime = Date.now(); updateWidgetStep(currentStepIndex, step.label, "running", { timeoutMs: step.type === "loop-group" ? undefined : step.timeoutMs, maxLoops: step.maxLoops, startedAt: stepStartTime, }); try { if (step.type === "loop-group") { await executeLoopGroup(ctx, step, state, agentMap, prompt, planFileRelPathInner, mode, loopCounts, currentStepIndex); } else { await executeSingleStep(ctx, step, state, agentMap, prompt, planFileRelPathInner, mode, currentStepIndex); if (step.agentName === "planner") { planFileRelPathInner = findLatestPlanFile(_workflowCwd); // Update chain context so subsequent agents know where the plan is if (planFileRelPathInner) { updateChainContext("实施计划", `计划文件: ${planFileRelPathInner}\n` + `(可在 .pi-dev-output/pi-plans/ 中 grep UUID ${_workflowId} 找到)` ); } } } state.status = "done"; state.durationMs = Date.now() - stepStartTime; updateWidgetStep(currentStepIndex, step.label, "done", { durationMs: state.durationMs, loopCount: state.loopCount, maxLoops: step.maxLoops, timeoutMs: step.type === "loop-group" ? undefined : step.timeoutMs, }); } catch (err) { state.status = "failed"; state.durationMs = Date.now() - stepStartTime; state.error = err instanceof Error ? err.message : String(err); updateWidgetStep(currentStepIndex, step.label, "failed", { durationMs: state.durationMs, error: state.error, loopCount: state.loopCount, }); break; } setWidgetCurrentStep(currentStepIndex + 1); saveCheckpoint(_workflowCwd, buildCp()); } // ── Done ── _workflowRunning = false; // Archive checkpoint instead of deleting archiveCheckpointFile(_workflowCwd, planFileRelPathInner); // Send persistent result const taskSummary = extractTaskSummary(prompt); const finalState = buildWidgetState( mode, _widgetSteps, steps.length, _widgetStartTime, stepStates.every(s => s.status === "done" || s.status === "skipped") ? "done" : "failed", { toolCount: _widgetExtraToolCount, tokenCount: _widgetExtraTokenCount }, taskSummary, _workflowId, ); sendWorkflowResult(pi, finalState, prompt, _workflowType); // Cleanup widget after delay if (_cleanupTimer) clearTimeout(_cleanupTimer); _cleanupTimer = setTimeout(() => { _cleanupTimer = null; cleanupWidget(); }, 5000); function buildCp(): CheckpointData { return { version: 2, workflowId: _workflowId, createdAt: existingCp?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), prompt, mode, steps: stepStates, currentStepIndex, loopCounts, planFilePath: planFileRelPathInner, taskSummary: extractTaskSummary(prompt), workflowType: _workflowType, fileChanges: [..._workflowFileChanges], subAgentRuns: _workflowAgentRunHistory.length, filesModified: _workflowFileChanges.filter(c => c.type === "edit").length, filesCreated: _workflowFileChanges.filter(c => c.type === "new").length, agentRunHistory: [..._workflowAgentRunHistory], baseline: _workflowBaseline, }; } } // ═══════════════════════════════════════════════════════════════ // Main entry // ═══════════════════════════════════════════════════════════════ export interface WorkflowConfig { steps: WorkflowStepDef[]; } /** * Launch a workflow asynchronously. * Sets up the widget and runs steps in background. * Does NOT block - the caller (command handler) returns immediately. * * @param ctx 命令上下文 * @param pi Extension API * @param prompt 用户原始 prompt * @param config 工作流步骤配置 * @param workflowType 可选的类型标签(feat/fix/doc 等),用于完成消息 */ export async function runWorkflow( ctx: ExtensionCommandContext, pi: ExtensionAPI, prompt: string, config: WorkflowConfig, workflowType?: string, ): Promise { const { steps } = config; // ── Load & validate agents ── const agents = discoverAgents(); const agentMap = new Map(); for (const a of agents) agentMap.set(a.name, a); for (const step of steps) { const names: string[] = []; if (step.type === "auto" || step.type === "confirm") { if (step.agentName) names.push(step.agentName); } if (step.type === "loop-group") { if (step.loopAgentName) names.push(step.loopAgentName); if (step.reviewAgentName) names.push(step.reviewAgentName); } for (const n of names) { if (!agentMap.has(n)) { await uiSelect(ctx, `❌ 未找到 agent "${n}"`, ["确定"]); return; } } } // ── State init / checkpoint recovery ── let mode: WorkflowMode = "attended"; const stepStates: WorkflowStepState[] = steps.map(() => ({ status: "pending" })); let currentStepIndex = 0; let loopCounts: Record = {}; let planFileRelPath: string | undefined; let resumeFlow = false; const existingCp = loadCheckpointFromFile(ctx.cwd); if (existingCp) { const resume = await uiConfirm(ctx, "🔄 恢复工作流", `发现上次未完成的工作流(${existingCp.updatedAt}),是否继续?`); if (resume) { mode = existingCp.mode; Object.assign(stepStates, existingCp.steps); currentStepIndex = existingCp.currentStepIndex; loopCounts = existingCp.loopCounts; planFileRelPath = existingCp.planFilePath; resumeFlow = true; } else { archiveCheckpointFile(ctx.cwd); // archive old checkpoint } } if (!existingCp || !resumeFlow) { const modeChoice = await uiSelect(ctx, "🤖 选择工作流模式", [ "1. 值守(默认)— 自动流程,[]步骤需确认,循环需许可", "2. 完全信任 — 全自动运行,无需任何确认", "3. 完全值守 — 每一步都需用户确认", "4. 取消工作流", ]); if (!modeChoice || modeChoice.startsWith("4")) return; mode = modeChoice.startsWith("2") ? "full-auto" : modeChoice.startsWith("3") ? "full-attended" : "attended"; } // Save initial state _workflowCwd = ctx.cwd; _workflowPrompt = prompt; _workflowPlanFileRelPath = planFileRelPath; _workflowLoopCounts = loopCounts; _workflowCreatedAt = existingCp?.createdAt ?? new Date().toISOString(); _workflowType = workflowType; _workflowPi = pi; _workflowStepDefs = steps; _workflowFileChanges = existingCp?.fileChanges ? [...existingCp.fileChanges] : []; _workflowAgentRunHistory = existingCp?.agentRunHistory ? [...existingCp.agentRunHistory] : []; // Initialize or restore workflow UUID _workflowId = existingCp?.workflowId ?? uuidv7(); // Reset chain context for new workflow session resetChainContext(); // ── Baseline snapshot: restore from checkpoint or capture fresh ── if (resumeFlow && existingCp?.baseline) { _workflowBaseline = [...existingCp.baseline]; } else if (!resumeFlow) { captureBaseline(ctx.cwd); } saveCheckpoint(ctx.cwd, { version: 2, workflowId: _workflowId, createdAt: existingCp?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), prompt, mode, steps: stepStates, currentStepIndex, loopCounts, planFilePath: planFileRelPath, taskSummary: extractTaskSummary(prompt), workflowType, fileChanges: [..._workflowFileChanges], subAgentRuns: _workflowAgentRunHistory.length, filesModified: _workflowFileChanges.filter(c => c.type === "edit").length, filesCreated: _workflowFileChanges.filter(c => c.type === "new").length, agentRunHistory: [..._workflowAgentRunHistory], baseline: _workflowBaseline, }); // Initialize widget initWidget(ctx, mode, steps.length); for (let i = 0; i < steps.length; i++) { const isDoneState = stepStates[i]?.status === "done"; updateWidgetStep(i, steps[i]!.label, isDoneState ? "done" : "pending", { maxLoops: steps[i]!.maxLoops, timeoutMs: steps[i]!.type === "loop-group" ? undefined : steps[i]!.timeoutMs, }); // Pre-populate sub-steps for all steps (shows queued agents) populatePredefinedSubSteps(i); } // Set up abort controller & cancel callback _workflowAbortController = new AbortController(); setWorkflowCancelCallback(() => { _workflowAbortController?.abort(); _workflowRunning = false; if (_lastWorkflowCtx) { const finalState = buildWidgetState( _widgetMode, _widgetSteps, _widgetCurrentIdx, _widgetStartTime, "cancelled", undefined, extractTaskSummary(_workflowPrompt), _workflowId, ); updateWorkflowWidget(_lastWorkflowCtx, finalState); // ── Save final checkpoint before archiving ── const cancelCp: CheckpointData = { version: 2, workflowId: _workflowId, createdAt: _workflowCreatedAt, updatedAt: new Date().toISOString(), prompt: _workflowPrompt, mode: _widgetMode, steps: _widgetSteps.map(s => ({ status: s.status as WorkflowStepState["status"], durationMs: s.durationMs, loopCount: s.loopCount, error: s.error, })), currentStepIndex: _widgetCurrentIdx, loopCounts: { ..._workflowLoopCounts }, planFilePath: _workflowPlanFileRelPath, taskSummary: extractTaskSummary(_workflowPrompt), workflowType: _workflowType, fileChanges: [..._workflowFileChanges], subAgentRuns: _workflowAgentRunHistory.length, filesModified: _workflowFileChanges.filter(c => c.type === "edit").length, filesCreated: _workflowFileChanges.filter(c => c.type === "new").length, agentRunHistory: [..._workflowAgentRunHistory], baseline: _workflowBaseline, }; saveCheckpoint(_workflowCwd, cancelCp); // ── Send workflow result message for persistence ── if (_workflowPi) { sendWorkflowResult(_workflowPi, finalState, _workflowPrompt, _workflowType); } // ── Archive checkpoint on cancel too ── archiveCheckpointFile(_workflowCwd, _workflowPlanFileRelPath); if (_cleanupTimer) clearTimeout(_cleanupTimer); _cleanupTimer = setTimeout(() => { _cleanupTimer = null; cleanupWidget(); }, 5000); } }); // Collapse tools to show widget ctx.ui.setToolsExpanded(false); // ── Register terminal input handler (Esc to cancel, with double-press confirmation) ── if (ctx.hasUI) { let _lastEscPressTime = 0; _terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => { if (!matchesKey(data, Key.escape)) return undefined; if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) { const now = Date.now(); if (_lastEscPressTime > 0 && now - _lastEscPressTime < 3000) { // Second Esc press within 3s → confirm cancel ctx.ui.notify("⏹️ 正在停止工作流...", "warning"); cancelWorkflow(); _lastEscPressTime = 0; return { consume: true }; } // First Esc press (or expired) → show hint _lastEscPressTime = now; ctx.ui.notify("再次按下 Esc 键,停止 Workflow - 3秒内按下有效", "warning"); return { consume: true }; } return undefined; }); } // ── Register signal handlers (SIGINT/SIGTERM) for graceful shutdown ── registerSignalHandlers(); // ── Launch background execution (fire-and-forget) ── executeWorkflowBackground( ctx, pi, prompt, steps, agentMap, mode, stepStates, currentStepIndex, loopCounts, planFileRelPath, existingCp ?? undefined, ).catch((err) => { console.error("[workflow] Background execution error:", err); _workflowRunning = false; if (_lastWorkflowCtx) { updateWorkflowWidget(_lastWorkflowCtx, null); } // Clean up terminal input listener and signal handlers if (_terminalInputUnsubscribe) { _terminalInputUnsubscribe(); _terminalInputUnsubscribe = null; } cleanupSignalHandlers(); }); // Return immediately - execution continues in background } // ═══════════════════════════════════════════════════════════════ // Extension factory (no-op — imported by dev-prompts.ts) // ═══════════════════════════════════════════════════════════════ /** * Check if a workflow is currently running. */ export function isWorkflowRunning(): boolean { return _workflowRunning; } /** * Cancel the active workflow, if any. * Safe to call even when no workflow is running (no-op in that case). */ export function cancelActiveWorkflow(): void { if (_workflowRunning && _workflowAbortController && !_workflowAbortController.signal.aborted) { cancelWorkflow(); } } export default function (_pi: ExtensionAPI) { // workflow-engine is a helper module, not a standalone extension. }