UNPKG

@workflow-manager/runner

Version:

CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes

219 lines (218 loc) 8.86 kB
import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import matter from "gray-matter"; import { SUPPORTED_ADAPTERS, resolveTaskAdapter } from "./adapters.js"; const SKILL_NAME_PATTERN = /^[a-zA-Z0-9_.-]+$/; function hashContentSha256(content) { return createHash("sha256").update(content).digest("hex"); } function isSafeSkillName(name) { return !!name && SKILL_NAME_PATTERN.test(name); } function isAllowedLocalSkillSourcePath(source) { if (!source || path.isAbsolute(source) || source.includes("\\") || source.includes("..")) return false; const normalized = path.posix.normalize(source); const withoutDot = normalized.startsWith("./") ? normalized.slice(2) : normalized; if (!withoutDot.startsWith("skills/")) return false; return withoutDot.endsWith("/SKILL.md"); } function validateSkillEntry(name, entry, errors) { if (!isSafeSkillName(name)) { errors.push(`Invalid skill name: ${name}`); } if (!entry.content?.trim() && !entry.source?.trim()) { errors.push(`Skill "${name}" must define content or source`); } if (entry.source && !isAllowedLocalSkillSourcePath(entry.source)) { errors.push(`Skill "${name}" source must be under ./skills/**/SKILL.md`); } if (entry.contentSha256) { if (!/^[a-f0-9]{64}$/.test(entry.contentSha256)) { errors.push(`Skill "${name}" contentSha256 must be a 64-char lowercase hex SHA-256`); } else if (!entry.content?.trim()) { errors.push(`Skill "${name}" defines contentSha256 but has no content`); } else if (hashContentSha256(entry.content) !== entry.contentSha256) { errors.push(`Skill "${name}" contentSha256 does not match content`); } } if (entry.upstream) { if (entry.upstream.repo !== undefined && (!entry.upstream.repo || typeof entry.upstream.repo !== "string")) { errors.push(`Skill "${name}" upstream.repo must be a non-empty string when present`); } if (entry.upstream.ref !== undefined && (!entry.upstream.ref || typeof entry.upstream.ref !== "string")) { errors.push(`Skill "${name}" upstream.ref must be a non-empty string when present`); } if (entry.upstream.path !== undefined && (!entry.upstream.path || typeof entry.upstream.path !== "string")) { errors.push(`Skill "${name}" upstream.path must be a non-empty string when present`); } } } function normalizeWorkflow(data, source) { if (!data.key || !data.title || !Array.isArray(data.steps)) { throw new Error(`Invalid workflow ${source}: key, title, and steps are required`); } return { key: data.key, title: data.title, description: data.description, objectives: data.objectives ?? [], inputSchema: data.inputSchema ?? {}, outputSchema: data.outputSchema ?? {}, defaultRetryPolicy: data.defaultRetryPolicy ?? { maxAttempts: 1 }, skills: data.skills, steps: data.steps.map((s) => ({ ...s, dependsOn: s.dependsOn ?? [], retryPolicy: s.retryPolicy ?? data.defaultRetryPolicy ?? { maxAttempts: 1 }, validation: s.validation ?? { mode: "none", required: false, autoConfirm: true }, taskSpec: s.taskSpec ? { ...s.taskSpec, adapterKey: resolveTaskAdapter(s.taskSpec.adapterKey), init: { context: s.taskSpec.init?.context ?? {}, skills: s.taskSpec.init?.skills ?? [], mcps: s.taskSpec.init?.mcps ?? [], systemPrompts: s.taskSpec.init?.systemPrompts ?? [], model: s.taskSpec.init?.model, stateFrom: s.taskSpec.init?.stateFrom, }, } : undefined, })), }; } function findDependencyCycle(def) { const stepsByKey = new Map(def.steps.map((step) => [step.key, step])); const visiting = new Set(); const visited = new Set(); const path = []; const visit = (stepKey) => { if (visited.has(stepKey)) { return null; } const pathIndex = path.indexOf(stepKey); if (visiting.has(stepKey) && pathIndex >= 0) { return [...path.slice(pathIndex), stepKey]; } const step = stepsByKey.get(stepKey); if (!step) { return null; } visiting.add(stepKey); path.push(stepKey); for (const dependency of step.dependsOn ?? []) { const cycle = visit(dependency); if (cycle) { return cycle; } } path.pop(); visiting.delete(stepKey); visited.add(stepKey); return null; }; for (const step of def.steps) { const cycle = visit(step.key); if (cycle) { return cycle; } } return null; } export function parseWorkflowMarkdown(filePath) { const raw = fs.readFileSync(filePath, "utf-8"); const parsed = matter(raw); const data = parsed.data; return normalizeWorkflow(data, "markdown"); } export function parseWorkflowJson(filePath) { const raw = fs.readFileSync(filePath, "utf-8"); const data = JSON.parse(raw); return normalizeWorkflow(data, "json"); } export function parseWorkflowFile(filePath) { const ext = path.extname(filePath).toLowerCase(); if (ext === ".json") { return parseWorkflowJson(filePath); } return parseWorkflowMarkdown(filePath); } export function validateWorkflow(def) { const errors = []; const seen = new Set(); if (!def.key.trim()) { errors.push("Workflow key is required"); } if (!def.title.trim()) { errors.push("Workflow title is required"); } if (!Array.isArray(def.steps) || def.steps.length === 0) { errors.push("Workflow must define at least one step"); return errors; } for (const [name, entry] of Object.entries(def.skills ?? {})) { validateSkillEntry(name, entry, errors); } for (const step of def.steps) { if (!step.key || !step.key.trim()) { errors.push("Each step must define a non-empty key"); continue; } if (seen.has(step.key)) errors.push(`Duplicate step key: ${step.key}`); seen.add(step.key); if (!["task", "approval", "system"].includes(step.kind)) { errors.push(`Invalid step kind for ${step.key}: ${step.kind}`); } if (step.kind === "task" && !step.taskSpec) { errors.push(`Task step ${step.key} is missing taskSpec`); } if (step.kind === "approval" && !step.approvalSpec) { errors.push(`Approval step ${step.key} is missing approvalSpec`); } for (const dep of step.dependsOn ?? []) { if (!def.steps.some((s) => s.key === dep)) { errors.push(`Step ${step.key} depends on unknown step ${dep}`); } } const stateFrom = step.taskSpec?.init?.stateFrom; if (Array.isArray(stateFrom)) { for (const entry of stateFrom) { if (typeof entry !== "string" || !def.steps.some((s) => s.key === entry)) { errors.push(`Step ${step.key} has stateFrom referencing unknown step ${entry}`); } } } else if (stateFrom !== undefined && stateFrom !== "all" && stateFrom !== "none") { errors.push(`Step ${step.key} has an invalid stateFrom value`); } const adapter = step.taskSpec?.adapterKey; if (adapter && !SUPPORTED_ADAPTERS.includes(adapter)) { errors.push(`Unsupported adapter for ${step.key}: ${adapter}`); } const mode = step.validation?.mode ?? step.approvalSpec?.validation?.mode; if (mode && !["none", "human", "external", "agent"].includes(mode)) { errors.push(`Invalid validation mode for ${step.key}: ${mode}`); } if (step.approvalSpec?.validation?.mode === "agent") { errors.push(`Approval step ${step.key} cannot use agent validation`); } if (step.validation?.mode === "agent" && step.validation.agent?.adapterKey) { const validatorAdapter = step.validation.agent.adapterKey; if (!SUPPORTED_ADAPTERS.includes(validatorAdapter)) { errors.push(`Unsupported validator adapter for ${step.key}: ${validatorAdapter}`); } } } const cycle = findDependencyCycle(def); if (cycle) { errors.push(`Circular dependency detected: ${cycle.join(" -> ")}`); } return errors; }