/** * Workflow Extension — tool-workflow-script * * workflow-script tool,5 actions(FR-5:脚本领域收口为单 tool)。 * * Actions: * - generate: AI 生成临时脚本 → 写 .pi/workflows/.tmp/ * - lint: 静态检查脚本(调 engine/script-lint.ts lintScript) * - save: 临时脚本转固定(.tmp → .pi/workflows/) * - delete: 删除脚本(前查 isRunning 防删运行中脚本) * - list: 列出可用脚本(调 registry.loadAll) * * 层归属:Interface。依赖 Pi SDK + engine script-lint + infra workflow-files。 * * 参考:domain-models.md §FR-5(tool 收口 4→2)。 */ import { mkdirSync, writeFileSync } from "node:fs"; import { resolve as pathResolve } from "node:path"; import { StringEnum } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { type Static, Type } from "typebox"; import { guiComponent, type GuiContext, type GuiRenderResult, guiResult, isGuiCapable, } from "@xyz-agent/extension-protocol"; import type { WorkflowScriptRegistry } from "../orchestration/models/workflow-script-registry.ts"; import { lintScript } from "../orchestration/script-lint.ts"; import { deleteWorkflow, saveWorkflow } from "../orchestration/workflow-files.ts"; import { toGuiCtx } from "./gui-mappers.ts"; import { renderTextFallback } from "./views/format.ts"; import { parseResourceMetaDetailed } from "../shared/meta-parser.ts"; // ── Parameter schema ───────────────────────────────────────── const WorkflowScriptParams = Type.Object({ action: StringEnum(["generate", "lint", "save", "delete", "list"] as const, { description: "Script management action", }), name: Type.Optional( Type.String({ description: "Workflow script name (generate/lint/save/delete)" }), ), script: Type.Optional( Type.String({ description: "Complete JS workflow script content (generate only)" }), ), description: Type.Optional( Type.String({ description: "Workflow purpose (generate only)" }), ), newName: Type.Optional( Type.String({ description: "New name when saving a tmp script (save --as only)" }), ), }); export type ScriptParams = Static; // ── Tool result types (S3: typed details, replaces Record) ── /** * Discriminated union of `workflow-script` tool `details` payloads. * * Discriminant: `action`. `save`/`delete` may surface structured `ok:false` * details on failure (instead of bare `undefined`) so programmatic consumers * can distinguish error shape from success. */ export type WorkflowScriptToolDetails = | { action: "generate"; path: string; name: string; status: "ready"; __gui__?: GuiRenderResult } | { action: "lint"; name: string; valid: boolean; findingCount: number; __gui__?: GuiRenderResult } | { action: "list"; count: number; __gui__?: GuiRenderResult } | { action: "save"; name: string; ok: boolean; __gui__?: GuiRenderResult } | { action: "delete"; name: string; ok: boolean; __gui__?: GuiRenderResult }; /** Result returned by the `workflow-script` tool's execute. */ export interface TextContent { content: Array<{ type: "text"; text: string }>; details: WorkflowScriptToolDetails | undefined; isError?: boolean; } // ── GUI 协议 helpers ─────────────────────────────────────── /** * 为 details 附加 __gui__(RPC 模式下)。 * * 所有 5 个 action 都映射到 stats-line(单行统计,无复杂结构): * - generate: 显示生成的脚本名 * - lint: passed / N findings * - list: 脚本数量 * - save/delete: ok/warn */ function withScriptGui( result: TextContent, ctx?: GuiContext, ): TextContent { if (!ctx || !isGuiCapable(ctx) || !result.details) return result; const details = result.details; // union 各成员已声明 __gui__?,spread + 补字段类型安全,无需强转 return { ...result, details: { ...details, __gui__: guiResult(buildScriptGui(details)) }, }; } /** 按 WorkflowScriptToolDetails 构造 stats-line GuiComponent。 */ export function buildScriptGui(details: WorkflowScriptToolDetails) { switch (details.action) { case "generate": return guiComponent("stats-line", { items: [{ label: "generated", value: details.name, severity: "ok" }], }); case "lint": return guiComponent("stats-line", { items: [ { label: "lint", value: details.valid ? "passed" : `${details.findingCount} findings`, severity: details.valid ? "ok" : "warn", }, ], }); case "list": return guiComponent("stats-line", { items: [{ label: "scripts", value: String(details.count), severity: "ok" }], }); case "save": case "delete": return guiComponent("stats-line", { items: [ { label: details.action, value: details.name, severity: details.ok ? "ok" : "warn", }, ], }); default: // 防御性兜底:action 是有限联合类型,理论不可达。 // 若未来新增 action 忘了更新此 switch,返回中性 stats-line 而非 undefined。 return guiComponent("stats-line", { items: [{ label: "action", value: String((details as { action: string }).action), severity: "warn" }], }); } } // ── Tool registration ──────────────────────────────────────── /** * 注册 workflow-script tool(5 actions: generate/lint/save/delete/list)。 * * @param pi ExtensionAPI * @param registry WorkflowScriptRegistry * @param isRunning 判断脚本是否正在运行(delete 前防删运行中脚本;factory 传入) */ export function registerWorkflowScriptTool( pi: ExtensionAPI, registry: WorkflowScriptRegistry, isRunning: (name: string) => boolean, ): void { pi.registerTool({ name: "workflow-script", label: "Workflow Script", description: "Manage workflow scripts: generate (AI creates tmp script), lint (static check), " + "save (tmp→permanent), delete, list. Before generating a new script, use action:list " + "to check if an available workflow already " + "covers the use case. Replaces workflow-generate + workflow-lint tools.", promptSnippet: "Generate, lint, save, delete, or list workflow scripts", promptGuidelines: [ "generate: AI writes a tmp workflow script to .pi/workflows/.tmp/. Declare metadata as a /* @pi-meta */ YAML block comment (name/description/phases required; parameters JSON Schema + usage markdown optional). NOT a const meta variable. Generate round-trip-validates the YAML and reports line/col on error (common pitfall: patternProperties regex must use double backslash \\d, not \d).", "lint: Statically check a script for common API misuse (outputSchema, result.output, file state).", "save: Promote a tmp script to permanent (.pi/workflows/).", "delete: Remove a script (blocked if a run is active).", "list: Show all available workflow scripts with source tags. " + "Use this to discover available workflows (see injection) " + "and user-generated scripts before starting a run. After listing, start a script via " + "the workflow tool with action:run and the script name.", "CRITICAL ANTI-PATTERN: NEVER generate scripts for patterns already covered by available " + "workflows. These are BUILT-IN — use the workflow tool with action:run directly. " + "generate is for NOVEL orchestration patterns ONLY. When in doubt, action:list first, " + "then action:run — not action:generate.", ], parameters: WorkflowScriptParams, async execute( _toolCallId: string, params: ScriptParams, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext, ): Promise { let result: TextContent; switch (params.action) { case "generate": result = actionGenerate(params, signal); break; case "lint": result = await actionLint(params, registry); break; case "save": result = await actionSave(params); break; case "delete": result = actionDelete(params, registry, isRunning); break; case "list": result = await actionList(registry); break; default: // 防御性(schema StringEnum 先拦):throw(W4b)——pi 只对 execute throw 置 // isError:true,返回值里的 isError 被 agent-loop 丢弃(agent-loop.js:453-483)。 throw new Error(`Unknown action: ${String(params.action)}`); } // GUI 协议:RPC 模式下附加 __gui__ 到 details return withScriptGui(result, toGuiCtx(ctx)); }, renderCall(args: ScriptParams, theme: Theme, _context?: unknown) { const label = `workflow-script ${args.action}`; const name = args.name ?? ""; const text = theme.fg("toolTitle", theme.bold(`${label} `)) + theme.fg("accent", name); return new Text(text, 0, 0); }, renderResult( result: { content?: Array<{ type: string; text?: string }> }, _options: unknown, _theme: Theme, _context?: unknown, ) { return new Text(renderTextFallback(result), 0, 0); }, }); } // ── generate action ────────────────────────────────────────── export function actionGenerate(params: ScriptParams, signal: AbortSignal | undefined): TextContent { if (signal?.aborted) { // throw(W4b):pi 只对 execute throw 置 isError:true(返回值 isError 被丢弃) throw new Error("Operation aborted before start"); } const name = params.name; const script = params.script; if (!name || !script) { throw new Error("generate requires 'name' and 'script' parameters"); } // 1. Reject ESM syntax (Worker runs CJS); 'export const meta' 例外 const stripped = script.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, ""); if (/\bimport\s+(?:type\s+)?[\w{*]/.test(stripped)) { throw new Error( "Script uses ESM 'import' syntax. Workflow scripts run in a CJS Worker — use require() instead.", ); } const hasExportMeta = /\bexport\s+const\s+meta\s*=/.test(stripped); const otherExports = stripped.match(/\bexport\s+(?:const|let|var|function|default|\{)/g); if (otherExports && !hasExportMeta) { throw new Error( "Script uses ESM 'export' (non-meta). Use 'const meta = {...}' at top level instead.", ); } // 2. Validate meta declaration (/* @pi-meta */ new format preferred; legacy const meta accepted during transition — m0) const hasPiMeta = /\/\*\s*@pi-meta\s*\n/.test(script); const hasLegacyMeta = script.includes("const meta") || script.includes("export const meta"); if (!hasPiMeta && !hasLegacyMeta) { throw new Error( "Script must contain a meta declaration: a /* @pi-meta */ YAML block comment (preferred) or legacy const meta = { ... }. The block has the form: a block comment starting with /* @pi-meta followed by YAML (name/description/phases/parameters?/usage?), closed by */ on its own line.", ); } // 3. Check agent usage if (!/\bagent\s*\(/.test(stripped)) { throw new Error( "Script does not contain any agent() calls. A workflow must call agent() at least once.", ); } // 4. Syntax check (wrap in async IIFE like runtime) const cjsScript = script.replace(/\bexport\s+const\s+meta\b/, "const meta"); try { new Function(`(async () => { ${cjsScript} })();`); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); throw new Error(`Syntax error in script: ${msg}`); } // 4b. Round-trip: validate /* @pi-meta */ YAML before writing (v5 §4.7 / ERR4 — report linePos, don't write bad files) if (hasPiMeta) { const detailed = parseResourceMetaDetailed(script, "workflow"); if (!detailed.ok) { const loc = "linePos" in detailed && detailed.linePos ? ` (line ${detailed.linePos.line}, col ${detailed.linePos.col})` : ""; throw new Error( `Generated /* @pi-meta */ YAML cannot be parsed${loc}: ${detailed.error}. Common causes: YAML indent errors, patternProperties regex must use double backslash (\\d not \d), or a stray star-slash inside the YAML body. Fix the meta block and retry.`, ); } } // 5. Write to .tmp directory const tmpDir = pathResolve(".pi/workflows/.tmp"); mkdirSync(tmpDir, { recursive: true }); const filePath = pathResolve(tmpDir, `${name}.js`); writeFileSync(filePath, script, "utf-8"); return { content: [ { type: "text", text: `Generated workflow script: ${filePath}\nName: ${name}\nReady to run via the workflow tool.`, }, ], details: { action: "generate", path: filePath, name, status: "ready" }, }; } // ── lint action ────────────────────────────────────────────── async function actionLint( params: ScriptParams, registry: WorkflowScriptRegistry, ): Promise { const name = params.name; if (!name) { throw new Error("lint requires 'name' parameter"); } const source = await loadScriptSource(name, registry); if (!source) { const all = await registry.loadAll(); const available = all.filter((wf) => wf.available); const suggestions = available .map((wf) => ` - ${wf.name}: ${wf.meta.description || "(no description)"}`) .join("\n"); throw new Error( `Workflow '${name}' not found or not available.\nAvailable:\n${suggestions || " (none)"}`, ); } const result = lintScript(source); if (result.findings.length === 0) { return textResult(`✅ No issues found in '${name}'.`); } const lines = result.findings.map((f) => { const icon = f.severity === "error" ? "❌" : "⚠️"; return `${icon} L${f.line}: ${f.message}\n Suggestion: ${f.suggestion}`; }); return { content: [ { type: "text", text: `${result.valid ? "Warnings" : "Errors"} found in '${name}':\n\n${lines.join("\n\n")}`, }, ], details: { action: "lint", name, valid: result.valid, findingCount: result.findings.length }, isError: !result.valid, }; } /** * 加载脚本源码(lint 用)。通过 registry port 获取——registry 返回的 * WorkflowScript 自带 sourceCode(FR-2:registry 是唯一读文件处), * 不再穿透到 config-loader 直接扫文件系统。 */ async function loadScriptSource( name: string, registry: WorkflowScriptRegistry, ): Promise { const script = await registry.get(name); return script?.available ? script.sourceCode : undefined; } // ── save action ────────────────────────────────────────────── async function actionSave(params: ScriptParams): Promise { const name = params.name; if (!name) { throw new Error("save requires 'name' parameter (tmp script name)"); } try { const result = await saveWorkflow(name, params.newName); return { content: [{ type: "text", text: result }], details: { action: "save", name, ok: true }, }; } catch (err: unknown) { // throw(W4):pi 只对 execute throw 置 isError:true(返回值里的 isError 被 // agent-loop 丢弃,agent-loop.js:453-483)——文案原样进 toolResult。 const msg = err instanceof Error ? err.message : String(err); throw new Error(`Save failed: ${msg}`); } } // ── delete action ──────────────────────────────────────────── function actionDelete( params: ScriptParams, registry: WorkflowScriptRegistry, isRunning: (name: string) => boolean, ): TextContent { const name = params.name; if (!name) { throw new Error("delete requires 'name' parameter"); } // deleteWorkflow 内部检查 isRunning(防止删运行中脚本) try { const result = deleteWorkflow(name, isRunning); // 失效 registry 缓存(下次 list/get 重扫) registry.invalidate(); return { content: [{ type: "text", text: result }], details: { action: "delete", name, ok: true }, }; } catch (err: unknown) { // throw(W4):同 save——pi 契约只有 throw 才置 isError:true。 const msg = err instanceof Error ? err.message : String(err); throw new Error(`Delete failed: ${msg}`); } } // ── list action ────────────────────────────────────────────── async function actionList(registry: WorkflowScriptRegistry): Promise { try { const all = await registry.loadAll(); const available = all.filter((wf) => wf.available); if (available.length === 0) { return textResult("No workflow scripts available."); } const lines = available.map( (wf) => ` [${wf.source}] ${wf.name} — ${wf.meta.description || "(no description)"}`, ); return { content: [{ type: "text", text: `Available workflows:\n${lines.join("\n")}` }], details: { action: "list", count: available.length }, }; } catch (err: unknown) { // throw(W4b):list 失败改 throw(原 return isError 被 pi 丢弃),文案保持 const msg = err instanceof Error ? err.message : String(err); throw new Error(`List failed: ${msg}`); } } // ── helper ─────────────────────────────────────────────────── /** * 构造纯文本非错误结果(W4b:isError 参数已删除——pi 只对 execute throw 置 * isError:true,返回值里的 isError 被 agent-loop 丢弃(agent-loop.js:453-483), * 错误一律 throw,编译器兜底防回潮)。 */ function textResult(text: string): TextContent { return { content: [{ type: "text", text }], details: undefined, }; }