/** * Resolución de workflows — descubrimiento de archivos de workflow entre ubicaciones project/global * (listWorkflows, resolveWorkflow). La incumbencia "dónde viven los workflows" separada del engine * que los ejecuta. Layout de paths/runs/graphs vive en `lib/paths.ts`. */ import { existsSync } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { CONFIG_DIR_NAME, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent"; import { resolveInsideRoot } from "../lib/path-safety.js"; import { ensureDir, WORKFLOW_DIR, WORKFLOW_DRAFT_DIR } from "../lib/paths.js"; import type { WorkflowDefinition, WorkflowLocation, WorkflowRunRecord, WorkflowScope, WorkflowScopeInput, } from "../types.js"; import { getWorkflowPatternPath, resolveWorkflowPattern, WORKFLOW_PATTERN_CATALOG } from "./pattern-scaffolds.js"; const RESERVED_WORKFLOW_SUBDIRS = new Set(["drafts", "runs", "graphs", "sessions"]); function normalizeWorkflowName(input: string): string { const raw = input.trim().replaceAll("\\", "/"); if (!raw) throw new Error("Workflow name is required."); if (path.isAbsolute(raw)) throw new Error("Workflow name must be relative, not absolute."); if (raw.split("/").some((part) => part === "..")) throw new Error("Workflow name must not contain '..'."); if (!/^[a-zA-Z0-9._/-]+$/.test(raw)) { throw new Error("Workflow name may only contain letters, numbers, '.', '_', '-', and '/'."); } if (/\.(js|mjs|cjs)$/i.test(raw)) return raw; return `${raw}.js`; } function workflowDisplayName(relativePath: string): string { return relativePath.replace(/\.(js|mjs|cjs)$/i, ""); } function getLocations(ctx: ExtensionContext): WorkflowLocation[] { return [ { scope: "project", root: path.join(ctx.cwd, CONFIG_DIR_NAME, WORKFLOW_DRAFT_DIR), trusted: ctx.isProjectTrusted(), kind: "draft", }, { scope: "project", root: path.join(ctx.cwd, CONFIG_DIR_NAME, WORKFLOW_DIR), trusted: ctx.isProjectTrusted(), kind: "workflow", }, { scope: "global", root: path.join(getAgentDir(), WORKFLOW_DRAFT_DIR), trusted: true, kind: "draft", }, { scope: "global", root: path.join(getAgentDir(), WORKFLOW_DIR), trusted: true, kind: "workflow", }, ]; } function resolveBuiltinScaffoldWorkflow(relativePath: string): WorkflowDefinition | undefined { const pattern = resolveWorkflowPattern(workflowDisplayName(relativePath)); if (!pattern) return undefined; const assetPath = getWorkflowPatternPath(pattern); // Los bundles de tests pueden omitir assets para aislar otra superficie. En una instalación real, // package.json los envía; si faltan, no los anunciamos como workflows ejecutables fantasma. if (!existsSync(assetPath)) return undefined; return { name: pattern.key, scope: "global", path: assetPath, relativePath: `scaffolds/${pattern.key}.js`, origin: "scaffold", readOnly: true, }; } function requireTrustedProject(ctx: ExtensionContext): void { if (!ctx.isProjectTrusted()) { throw new Error(`Project workflows require a trusted project. Run /trust or use scope=global.`); } } async function walkWorkflowFiles( root: string, options: { skipReservedTopLevelDirs?: boolean } = {}, ): Promise { if (!existsSync(root)) return []; const out: string[] = []; async function walk(dir: string): Promise { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (options.skipReservedTopLevelDirs && dir === root && RESERVED_WORKFLOW_SUBDIRS.has(entry.name)) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { await walk(full); continue; } if (entry.isFile() && /\.(js|mjs|cjs)$/i.test(entry.name)) { out.push(full); } } } await walk(root); return out.sort(); } export async function listWorkflows(ctx: ExtensionContext): Promise { const files: WorkflowDefinition[] = []; for (const location of getLocations(ctx)) { if (!location.trusted) continue; for (const file of await walkWorkflowFiles(location.root, { skipReservedTopLevelDirs: location.kind === "workflow", })) { const relativePath = path.relative(location.root, file).replaceAll(path.sep, "/"); files.push({ name: workflowDisplayName(relativePath), scope: location.scope, path: file, relativePath, }); } } for (const pattern of WORKFLOW_PATTERN_CATALOG) { const builtin = resolveBuiltinScaffoldWorkflow(`${pattern.key}.js`); if (builtin) files.push(builtin); } return files; } export async function resolveWorkflow( ctx: ExtensionContext, name: string, scope: WorkflowScopeInput = "auto", forWrite: false | "draft" | "workflow" = false, ): Promise { const relativePath = normalizeWorkflowName(name); const locations = getLocations(ctx); if (forWrite) { const targetScope: WorkflowScope = scope === "global" ? "global" : "project"; if (targetScope === "project") requireTrustedProject(ctx); const targetKind: WorkflowLocation["kind"] = forWrite; const location = locations.find((loc) => loc.scope === targetScope && loc.kind === targetKind)!; await ensureDir(location.root); const file = resolveInsideRoot( location.root, path.join(location.root, relativePath), relativePath, "workflow directory", ); return { name: workflowDisplayName(relativePath), scope: targetScope, path: file, relativePath, }; } const candidates = scope === "auto" ? locations : locations.filter((loc) => loc.scope === scope); for (const location of candidates) { if (!location.trusted) continue; const file = path.join(location.root, relativePath); if (existsSync(file)) { const safeFile = resolveInsideRoot(location.root, file, relativePath, "workflow directory"); return { name: workflowDisplayName(relativePath), scope: location.scope, path: safeFile, relativePath, }; } } if (scope !== "project") { const builtin = resolveBuiltinScaffoldWorkflow(relativePath); if (builtin) return builtin; } if (scope === "project" && !ctx.isProjectTrusted()) requireTrustedProject(ctx); throw new Error(`Workflow not found: ${name}`); } export async function resolveWorkflowForRun( ctx: ExtensionContext, run: WorkflowRunRecord, ): Promise { try { return await resolveWorkflow(ctx, run.workflow, run.scope); } catch { if (run.file && existsSync(run.file)) { return { name: run.workflow, scope: run.scope, path: run.file, relativePath: path.basename(run.file), }; } return undefined; } } export function parsePatternFlag(raw: string | undefined): string | undefined { const value = raw?.trim(); if (!value) return undefined; const match = /(?:^|\s)--pattern(?:=|\s+)([^\s]+)/.exec(value) ?? /(?:^|\s)--from-pattern(?:=|\s+)([^\s]+)/.exec(value); return match?.[1]?.replace(/^['"]|['"]$/g, ""); }