/** * YAML/JSON parsing for workflow files. * * A workflow can be authored as YAML (the ergonomic form) or JSON (the canonical form the * app/editor writes). Both parse to a plain object that `Workflow.parse` (schema.ts) then * validates STRICTLY — so this layer only turns text into data; it makes no trust * decisions. `yaml.parse` is data-only (no anchors-as-code, no custom tags executed), which * keeps a hand-authored or imported file from smuggling behavior through the parser itself. */ import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; import { Workflow, validateWorkflow, newWorkflowId } from "./schema.ts"; export type WorkflowFormat = "yaml" | "json"; // Guess the format from a filename extension. Defaults to YAML (the authoring form). export function formatForPath(path: string): WorkflowFormat { return /\.json$/i.test(path) ? "json" : "yaml"; } // Parse workflow text (YAML or JSON) into a raw object. Throws on syntactically invalid // text. Does NOT schema-validate — call parseWorkflowText/loadWorkflowFromText for that. export function parseWorkflowData(raw: string, format: WorkflowFormat = "yaml"): unknown { return format === "json" ? JSON.parse(raw) : parseYaml(raw); } export interface ParseResult { ok: boolean; workflow?: Workflow; // First human-readable error (schema issue, graph hole, or a YAML/JSON syntax error). error?: string; } /** * Parse + fully validate workflow text into a Workflow. Mints a `workflow.id` when the * file omits one (hand-authored YAML usually does), so an authored file need not carry the * bookkeeping id. Returns a discriminated result rather than throwing — a bad file is data, * not an exception. */ export function parseWorkflowText(raw: string, format: WorkflowFormat = "yaml"): ParseResult { let data: unknown; try { data = parseWorkflowData(raw, format); } catch (err) { return { ok: false, error: `syntax error: ${err instanceof Error ? err.message : String(err)}` }; } if (!data || typeof data !== "object") return { ok: false, error: "workflow file is empty or not a mapping" }; // Inject a fresh id when absent so authored files don't need one; a present id is kept. const header = (data as { workflow?: Record }).workflow ?? {}; const withId = typeof header.id === "string" && header.id ? data : { ...(data as object), workflow: { ...header, id: newWorkflowId() } }; const parsed = Workflow.safeParse(withId); if (!parsed.success) { const first = parsed.error.issues[0]; return { ok: false, error: first ? `${first.path.join(".") || "workflow"} — ${first.message}` : "schema error" }; } const graphErrors = validateWorkflow(parsed.data); if (graphErrors.length > 0) return { ok: false, error: graphErrors[0] }; return { ok: true, workflow: parsed.data }; } // Serialize a Workflow to YAML (for exporting/authoring) or JSON (canonical). export function stringifyWorkflow(wf: Workflow, format: WorkflowFormat = "yaml"): string { return format === "json" ? JSON.stringify(wf, null, 2) + "\n" : stringifyYaml(wf); }