UNPKG

@mastra/core

Version:
1,310 lines 50.5 kB
import { standardSchemaToJSONSchema, toStandardSchema } from "./schema/index.js"; import { $t as createWorkflow, Zt as cloneWorkflow, bn as createStepFromTool, fn as mapVariable, mn as derivePredicateLabel, pn as predicateToCondition, yn as createStepFromAgent } from "./agent-Dj30gJa3.js"; import { l as collectTemplateStepIds, u as validateTemplate, v as getSingleStepEntryId } from "./workflow-event-processor-BbED1LMn.js"; import { z } from "zod"; //#region src/workflows/stored/json-schema-to-zod.ts /** * Minimal JSON-Schema ↔ Zod bridge for stored workflows: a converter for the * static subset Zod round-trips through `standardSchemaToJSONSchema`, plus a * non-throwing validator for the write path. */ /** * Inline converter sufficient for the static subset Zod typically emits when * round-tripped through `standardSchemaToJSONSchema`. Handles: * * - `object` with `properties` + `required` * - `string` / `number` / `integer` / `boolean` / `null` * - `array` with `items` * - `enum` * - `description` (propagated via `.describe`) * * For more exotic schemas (unions, intersections, recursive refs) swap in * `json-schema-to-zod` from npm. Kept inline to avoid pulling a dependency * for the MVP demo. */ function jsonSchemaToZod(schema, opts) { return walk(schema, opts ?? {}); } const UNSUPPORTED_SCHEMA_KEYS = [ "oneOf", "anyOf", "allOf", "not", "$ref", "patternProperties", "discriminator" ]; /** Values `z.literal()` can represent — the only const/enum members that survive conversion losslessly. */ function isLiteralValue(v) { return v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean"; } /** Throw or warn-and-fallback per `onUnsupportedSchema`, matching the unsupported-keyword behavior. */ function unsupported(message, opts) { if (opts.onUnsupportedSchema === "warn") { opts.onUnsupported?.(message); return z.any(); } throw new Error(message); } function walk(schema, opts) { if (!schema || typeof schema !== "object") return z.any(); for (const key of UNSUPPORTED_SCHEMA_KEYS) if (key in schema) return unsupported(`Stored workflow schema uses unsupported JSON Schema keyword "${key}". This converter only supports the static subset that Zod round-trips through standardSchemaToJSONSchema (object, array, string, number, integer, boolean, null, enum, const). Simplify the schema or extend jsonSchemaToZod to cover this keyword.`, opts); let out; if ("const" in schema) { if (!isLiteralValue(schema.const)) return unsupported(`Stored workflow schema uses a non-primitive "const" value (${JSON.stringify(schema.const)}). Only string, number, boolean, and null literals are supported.`, opts); out = z.literal(schema.const); } else if (Array.isArray(schema.enum) && schema.enum.length > 0) { const values = schema.enum; if (!values.every(isLiteralValue)) return unsupported("Stored workflow schema uses an \"enum\" with non-primitive members. Only string, number, boolean, and null enum members are supported.", opts); if (values.every((v) => typeof v === "string")) out = z.enum(values); else { const literals = values.map((v) => z.literal(v)); out = literals.length === 1 ? literals[0] : z.union(literals); } } else if (Array.isArray(schema.type)) { const options = schema.type.map((t) => walk({ ...schema, type: t }, opts)); if (options.length === 1) out = options[0]; else out = z.union(options); } else switch (schema.type) { case "object": { const shape = {}; const required = new Set(Array.isArray(schema.required) ? schema.required : []); for (const [key, child] of Object.entries(schema.properties ?? {})) { const childSchema = walk(child, opts); shape[key] = required.has(key) ? childSchema : childSchema.optional(); } const obj = z.object(shape); out = schema.additionalProperties === true ? obj.passthrough() : obj; break; } case "array": if (Array.isArray(schema.items)) return unsupported("Stored workflow schema uses tuple-form \"items\" (an array of positional schemas). Only a single item schema is supported; use \"items\": { ... } instead.", opts); out = z.array(walk(schema.items ?? {}, opts)); break; case "string": out = z.string(); break; case "number": out = z.number(); break; case "integer": out = z.number().int(); break; case "boolean": out = z.boolean(); break; case "null": out = z.null(); break; case void 0: out = z.any(); break; default: return unsupported(`Stored workflow schema uses unsupported JSON Schema type "${String(schema.type)}". This converter only supports object, array, string, number, integer, boolean, null, and enum.`, opts); } if (typeof schema.description === "string" && schema.description.length > 0) out = out.describe(schema.description); return out; } /** * Non-throwing companion to `jsonSchemaToZod`. Walks a JSON Schema and reports * every unsupported-keyword usage without converting. Use this at write time * (e.g. inside `Mastra.addStoredWorkflow`) to surface a warning before the * schema is persisted — the row will still fail to rehydrate on the next boot * (`jsonSchemaToZod` throws), so this is a heads-up, not a guarantee. * * Callers decide whether to warn, reject, or ignore. This function never * throws for any input shape. */ function validateStorableJsonSchema(schema) { if (!schema || typeof schema !== "object") return { ok: true }; const unsupported = []; const visit = (node, path) => { if (!node || typeof node !== "object") return; const n = node; for (const key of UNSUPPORTED_SCHEMA_KEYS) if (key in n) unsupported.push(`${path || "#"}: ${key}`); if (n.properties && typeof n.properties === "object") for (const [prop, child] of Object.entries(n.properties)) visit(child, `${path}/properties/${prop}`); if (n.items) if (Array.isArray(n.items)) n.items.forEach((child, i) => visit(child, `${path}/items/${i}`)); else visit(n.items, `${path}/items`); if (n.additionalProperties && typeof n.additionalProperties === "object") visit(n.additionalProperties, `${path}/additionalProperties`); }; visit(schema, ""); return unsupported.length === 0 ? { ok: true } : { ok: false, unsupported }; } //#endregion //#region src/workflows/stored/validate/schema-utils.ts /** * Pure JSON-Schema helpers shared by schema-flow analysis and mapping-config * analysis. Everything here is best-effort and three-valued: a check only * reports `incompatible` when it can prove a mismatch, so absent or partial * schemas degrade to `unknown` instead of producing false positives. */ /** * Best-effort conversion of a live (Zod / standard) schema to JSON Schema for * registry-index building. Unconvertible or absent schemas yield `undefined` * ("unknown"), which schema-flow treats as never-incompatible. */ function toJsonSchemaOrUndefined(schema) { if (schema === void 0 || schema === null) return void 0; try { return standardSchemaToJSONSchema(toStandardSchema(schema)); } catch { return; } } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** True when both types are numeric, i.e. some mix of `integer` and `number`. */ function isNumeric(sourceType, destinationType) { const numeric = /* @__PURE__ */ new Set(["integer", "number"]); return numeric.has(sourceType) && numeric.has(destinationType); } /** * Structural compatibility of `source` output feeding a `destination` input. * Recurses through array items and object properties; a destination `required` * key missing from the source is a proven incompatibility. */ function schemaCompatibility(source, destination) { if (!isRecord(source) || !isRecord(destination)) return "unknown"; const sourceType = typeof source.type === "string" ? source.type : void 0; const destinationType = typeof destination.type === "string" ? destination.type : void 0; if (!sourceType || !destinationType) return "unknown"; if (sourceType !== destinationType && !isNumeric(sourceType, destinationType)) return "incompatible"; if (destinationType === "array") return schemaCompatibility(source.items, destination.items); if (destinationType !== "object") return "compatible"; const sourceProperties = isRecord(source.properties) ? source.properties : {}; const destinationProperties = isRecord(destination.properties) ? destination.properties : {}; const required = Array.isArray(destination.required) ? destination.required.filter((key) => typeof key === "string") : []; for (const key of required) if (!(key in sourceProperties)) return "incompatible"; for (const [key, destinationProperty] of Object.entries(destinationProperties)) { if (!(key in sourceProperties)) continue; if (schemaCompatibility(sourceProperties[key], destinationProperty) === "incompatible") return "incompatible"; } return "compatible"; } /** Follows a dotted mapping path through object `properties`; `''`/`'.'` is the root. */ function schemaAtPath(schema, path) { if (!schema || path === "" || path === ".") return schema; let current = schema; for (const segment of path.split(".")) { if (!isRecord(current) || !isRecord(current.properties) || !isRecord(current.properties[segment])) return void 0; current = current.properties[segment]; } return current; } /** Plain dotted segments only — no `$.`, brackets, or empty segments. */ function isCanonicalMappingPath(path) { return path === "" || path === "." || /^[^.[$\]]+(?:\.[^.[$\]]+)*$/.test(path); } /** Infers a JSON Schema for a literal `{ value }` mapping source. */ function schemaForValue(value) { if (value === null) return { type: "null" }; if (Array.isArray(value)) return { type: "array" }; switch (typeof value) { case "string": case "boolean": return { type: typeof value }; case "number": return { type: Number.isInteger(value) ? "integer" : "number" }; case "object": return { type: "object" }; default: return {}; } } //#endregion //#region src/workflows/stored/mapping-config.ts /** * The single home for stored `mapConfig` handling. * * A mapping entry's config crosses the storage boundary as a JSON string. * - {@link parseMapConfig} is the one parser (rehydration + validation both * use it; rehydration via the throwing form). * - {@link analyzeMapConfig} is the one validator: it walks each descriptor, * collects issues, and infers the mapping's output schema in the same pass * (the two are inseparable — a descriptor's validity determines its * contribution to the output shape). * * Template syntax checking delegates to `mapping-template.ts`'s * `validateTemplate` — the same parser the runtime uses — plus a scope check * over the placeholders' step ids. */ /** Parses a stored mapConfig JSON string; throws with the step id on malformed JSON. */ function parseMapConfig(raw, stepId) { try { return JSON.parse(raw); } catch (e) { throw new Error(`Stored mapping step "${stepId}" has invalid JSON mapConfig: ${e.message}`); } } /** A recognizable Handlebars/Mustache placeholder: `{{ name }}`, `{{a.b}}`, … */ const HANDLEBARS_PLACEHOLDER = /\{\{\s*[\w$][\w.$-]*\s*\}\}/; /** * Validates a mapping entry's raw `mapConfig` string and infers the step's * output schema. Every key must define exactly one source * (`value` | `template` | `requestContextPath` | `initData`/`step` + `path`); * step references must point at preceding workflow-local steps. */ function analyzeMapConfig(rawConfig, opts) { const issues = []; const { path, availableOutputs } = opts; let config; try { config = JSON.parse(rawConfig); } catch { config = void 0; } if (!isRecord(config)) { issues.push({ code: "invalid-map-config", path: `${path}.mapConfig`, message: "Mapping config must be a JSON object." }); return { issues, outputSchema: void 0 }; } const properties = {}; for (const [key, descriptor] of Object.entries(config)) { const descriptorPath = `${path}.mapConfig.${key}`; if (!isRecord(descriptor)) { issues.push({ code: "invalid-map-config", path: descriptorPath, message: "Mapping descriptor must be an object." }); continue; } if ([ "value" in descriptor, typeof descriptor.template === "string", typeof descriptor.requestContextPath === "string", "path" in descriptor ].filter(Boolean).length !== 1) { issues.push({ code: "invalid-map-config", path: descriptorPath, message: "Mapping descriptor must define exactly one source." }); continue; } if ("value" in descriptor) { properties[key] = schemaForValue(descriptor.value); continue; } if (typeof descriptor.template === "string") { let syntaxError; try { validateTemplate(descriptor.template); } catch (err) { syntaxError = err.message; } if (syntaxError === void 0 && HANDLEBARS_PLACEHOLDER.test(descriptor.template)) syntaxError = `Templates use \${...} placeholders (e.g. "\${initData.name}"), not {{...}}. "${descriptor.template}" would be emitted literally.`; const unknownStep = syntaxError === void 0 ? collectTemplateStepIds(descriptor.template).find((stepId) => !availableOutputs.has(stepId)) : void 0; if (syntaxError !== void 0 || unknownStep !== void 0) issues.push({ code: "invalid-map-reference", path: `${descriptorPath}.template`, message: syntaxError ?? "Template references must use an available workflow-local source." }); properties[key] = { type: "string" }; continue; } if (typeof descriptor.requestContextPath === "string") { if (!isCanonicalMappingPath(descriptor.requestContextPath) || descriptor.requestContextPath === "") issues.push({ code: "invalid-map-config", path: `${descriptorPath}.requestContextPath`, message: "Mapping paths must use plain dotted segments." }); properties[key] = schemaAtPath(opts.requestContextSchema, descriptor.requestContextPath) ?? {}; continue; } if (typeof descriptor.path !== "string" || !isCanonicalMappingPath(descriptor.path)) { issues.push({ code: "invalid-map-config", path: `${descriptorPath}.path`, message: "Mapping paths must use plain dotted segments." }); continue; } const hasInitData = descriptor.initData === true; const stepIds = typeof descriptor.step === "string" ? [descriptor.step] : Array.isArray(descriptor.step) ? descriptor.step : []; if (hasInitData === stepIds.length > 0 || stepIds.some((stepId) => typeof stepId !== "string")) { issues.push({ code: "invalid-map-config", path: descriptorPath, message: "Path mappings must reference exactly one of initData or step." }); continue; } let sourceSchema; if (hasInitData) sourceSchema = opts.inputSchema; else { const missing = stepIds.find((stepId) => !availableOutputs.has(stepId)); if (missing) { issues.push({ code: "invalid-map-reference", path: `${descriptorPath}.step`, message: `Mapping source "${missing}" must be a preceding workflow-local step.` }); continue; } sourceSchema = stepIds.map((stepId) => availableOutputs.get(stepId)).find(Boolean); } const selectedSchema = schemaAtPath(sourceSchema, descriptor.path); if (sourceSchema && !selectedSchema) issues.push({ code: "invalid-map-config", path: `${descriptorPath}.path`, message: `Path "${descriptor.path}" does not exist in the source schema.` }); properties[key] = selectedSchema ?? {}; } return { issues, outputSchema: { type: "object", properties, required: Object.keys(config) } }; } //#endregion //#region src/workflows/stored/rehydrate.ts async function rehydrateWorkflow(def, mastra, opts) { const inputSchema = jsonSchemaToZod(def.inputSchema, opts); const outputSchema = jsonSchemaToZod(def.outputSchema, opts); const stateSchema = def.stateSchema ? jsonSchemaToZod(def.stateSchema, opts) : void 0; const requestContextSchema = def.requestContextSchema ? jsonSchemaToZod(def.requestContextSchema, opts) : void 0; const wf = createWorkflow({ id: def.id, description: def.description, metadata: def.metadata, inputSchema, outputSchema, stateSchema, requestContextSchema }); for (const entry of def.graph) applyGraphEntry(wf, entry, mastra, opts); const built = wf.commit(); built.origin = "stored"; return { workflow: built }; } function applyGraphEntry(wf, entry, mastra, schemaOpts) { switch (entry.type) { case "agent": case "tool": wf.__pushStepFlowEntry(rehydrateSingleEntry(entry, mastra, schemaOpts), entry); return; case "mapping": { const live = rehydrateMapConfig(parseMapConfig(entry.mapConfig, entry.id), mastra); wf.map(live, { id: entry.id }); return; } case "sleep": { if (typeof entry.duration !== "number") throw new Error(`Stored sleep "${entry.id}" missing literal duration.`); const live = { type: "sleep", id: entry.id, duration: entry.duration }; wf.__pushStepFlowEntry(live, live); return; } case "sleepUntil": { if (!(entry.date instanceof Date) && typeof entry.date !== "string") throw new Error(`Stored sleepUntil "${entry.id}" missing literal date.`); const date = entry.date instanceof Date ? entry.date : new Date(entry.date); if (Number.isNaN(date.getTime())) throw new Error(`Stored sleepUntil "${entry.id}" has an unparseable date: ${String(entry.date)}`); const live = { type: "sleepUntil", id: entry.id, date }; wf.__pushStepFlowEntry(live, { type: "sleepUntil", id: entry.id, date }); return; } case "parallel": { const live = { type: "parallel", steps: entry.steps.map((s) => rehydrateSingleEntry(s, mastra, schemaOpts)) }; wf.__pushStepFlowEntry(live, entry); return; } case "foreach": { if (entry.step.type === "mapping") throw new Error(`Foreach step cannot iterate a mapping: mappings project data, they don't execute per item. Use an agent, tool, or plain step as the foreach body.`); const live = { type: "foreach", step: rehydrateSingleEntry(entry.step, mastra, schemaOpts), opts: { concurrency: entry.opts?.concurrency ?? 1 } }; wf.__pushStepFlowEntry(live, entry); return; } case "step": { const live = rehydrateSingleEntry(entry, mastra, schemaOpts); wf.__pushStepFlowEntry(live, entry); return; } case "workflow": { const nested = assertWorkflowExists(mastra, entry.workflowId); wf.then(entry.id && entry.id !== nested.id ? cloneWorkflow(nested, { id: entry.id }) : nested); return; } case "conditional": { const predicates = entry.predicates; if (!predicates || predicates.length !== entry.steps.length || predicates.some((p) => !p)) throw new Error(`Cannot rehydrate conditional step: missing or mismatched predicates. Only declarative predicate branches round-trip.`); const steps = entry.steps.map((s) => rehydrateSingleEntry(s, mastra, schemaOpts)); const serializedConditions = entry.serializedConditions ?? steps.map((s, i) => ({ id: `${getSingleStepEntryId(s)}-condition`, fn: derivePredicateLabel(predicates[i]) })); const live = { type: "conditional", steps, conditions: predicates.map((p) => predicateToCondition(p)), serializedConditions, predicates }; wf.__pushStepFlowEntry(live, { ...entry, serializedConditions }); return; } case "loop": { const { predicate, loopType } = entry; if (!predicate || loopType !== "dowhile" && loopType !== "dountil") throw new Error(`Cannot rehydrate loop step: missing declarative predicate or loopType. Only declarative predicate loops round-trip.`); const step = rehydrateSingleEntry(entry.step, mastra, schemaOpts); const serializedCondition = entry.serializedCondition ?? { id: `${getSingleStepEntryId(step)}-condition`, fn: derivePredicateLabel(predicate) }; const live = { type: "loop", step, condition: predicateToCondition(predicate), loopType, serializedCondition, predicate }; wf.__pushStepFlowEntry(live, { ...entry, serializedCondition }); return; } default: throw new Error(`Unknown stored step type: ${JSON.stringify(entry)}`); } } /** * Reconstruct the options bag `.agent()` accepts from a serialized entry. * Restores `structuredOutput.schema` from `outputSchema` (JSON Schema → Zod) * and merges in `retries` / `metadata`. Returns `undefined` when nothing to * restore so `.agent(agentId)` stays a clean call. */ function rebuildAgentOptions(entry, schemaOpts) { const opts = {}; if (entry.outputSchema) opts.structuredOutput = { schema: jsonSchemaToZod(entry.outputSchema, schemaOpts) }; if (entry.options?.retries !== void 0) opts.retries = entry.options.retries; if (entry.options?.metadata !== void 0) opts.metadata = entry.options.metadata; return Object.keys(opts).length > 0 ? opts : void 0; } function rebuildToolOptions(entry) { const opts = {}; if (entry.options?.retries !== void 0) opts.retries = entry.options.retries; if (entry.options?.metadata !== void 0) opts.metadata = entry.options.metadata; return Object.keys(opts).length > 0 ? opts : void 0; } /** * Build the live `SingleStepEntry` for a stored entry. Declarative agent/tool * entries stay declarative — both engines interpret them per-kind at * execution time (`runAgentEntry` / `runToolEntry`) — so no fake `Step` * wrapper is needed and the stored `id` / `outputSchema` / `retries` / * `metadata` round-trip losslessly in every position (top-level, parallel, * branch, foreach and loop bodies). * * `step` descriptors resolve agent-then-tool by id against the live Mastra * instance; `workflow` entries resolve the registered instance. Both become * plain `{ type: 'step' }` entries, same as the fluent builder emits. */ function rehydrateSingleEntry(entry, mastra, schemaOpts) { switch (entry.type) { case "agent": { const agent = tryGetAgentById(mastra, entry.agentId); if (!agent) throw new Error(`Stored workflow references agent "${entry.agentId}" which is not registered on this Mastra instance.`); return { type: "agent", id: entry.id, agentId: entry.agentId, agent, options: rebuildAgentOptions(entry, schemaOpts) }; } case "tool": { const tool = mastra.getTool?.(entry.toolId); if (!tool) throw new Error(`Stored workflow references tool "${entry.toolId}" which is not registered on this Mastra instance.`); return { type: "tool", id: entry.id, toolId: entry.toolId, tool, options: rebuildToolOptions(entry) }; } case "step": { const { id } = entry.step; const agent = tryGetAgentById(mastra, id); if (agent) return { type: "step", step: createStepFromAgent(agent) }; const tool = tryGetToolById(mastra, id); if (tool) return { type: "step", step: createStepFromTool(tool) }; throw new Error(`Stored workflow references step "${id}" which is not registered as an agent or tool on this Mastra instance.`); } case "workflow": { const nested = assertWorkflowExists(mastra, entry.workflowId); return { type: "step", step: entry.id && entry.id !== nested.id ? cloneWorkflow(nested, { id: entry.id }) : nested }; } case "mapping": throw new Error(`mapping entries cannot appear inside .parallel(), .branch(), or .foreach(); they must be top-level.`); } } /** * Rebuild the object shape that `.map()` accepts. Step sources remain workflow-local * step IDs because mapping execution resolves them from the run's step results. */ function rehydrateMapConfig(cfg, mastra) { const out = {}; for (const [key, source] of Object.entries(cfg)) { if (!source || typeof source !== "object") { out[key] = source; continue; } if ("template" in source) out[key] = { template: source.template }; else if ("value" in source) out[key] = { value: source.value }; else if ("requestContextPath" in source) out[key] = { requestContextPath: source.requestContextPath }; else if ("initData" in source && typeof source.initData === "string") { const wf = mastra.getWorkflow?.(source.initData); if (!wf) throw new Error(`Mapping references unknown workflow init-data "${source.initData}".`); out[key] = mapVariable({ initData: wf, path: source.path }); } else if ("step" in source) out[key] = mapVariable({ step: source.step, path: source.path }); else out[key] = source; } return out; } /** * Mastra.getAgentById throws when the id isn't registered; every by-id * resolution path in this file wants a nullable "does it exist?" answer so it * can fall through to a tool lookup or a targeted error. Swallow the not-found * throw and return undefined. */ function tryGetAgentById(mastra, id) { if (!id || typeof mastra.getAgentById !== "function") return void 0; try { return mastra.getAgentById(id); } catch { return; } } /** Same nullable-lookup contract as `tryGetAgentById`, for tools — `Mastra.getTool` throws on a missing id. */ function tryGetToolById(mastra, id) { if (!id || typeof mastra.getTool !== "function") return void 0; try { return mastra.getTool(id); } catch { return; } } /** * Workflow references resolve like agent references: intrinsic workflow id * first (`getWorkflowById` scans registered workflows by their own `id`), * falling back to the registration key. Stored definitions reference the * intrinsic id — the identity discovery advertises — which may differ from * the key the workflow was registered under (`workflows: { greetingWorkflow }` * vs `id: 'greeting-workflow'`). */ function tryGetWorkflowById(mastra, id) { if (!id) return void 0; if (typeof mastra.getWorkflowById === "function") try { return mastra.getWorkflowById(id); } catch {} if (typeof mastra.getWorkflow !== "function") return void 0; try { return mastra.getWorkflow(id); } catch { return; } } function assertWorkflowExists(mastra, workflowId) { const wf = tryGetWorkflowById(mastra, workflowId); if (!wf) throw new Error(`Stored workflow references nested workflow "${workflowId}" which is not registered on this Mastra instance.`); return wf; } //#endregion //#region src/workflows/stored/graph.ts /** * Invoke `visit` for every single-step (leaf) entry in the graph, recursing * into `parallel`/`conditional` children and `loop`/`foreach` bodies. * * Does NOT recurse into a nested workflow's inlined `serializedStepFlow` — * a nested workflow's own graph is validated when that workflow is added. * `sleep`/`sleepUntil` entries carry no references or schemas and are skipped. */ function forEachSingleStepEntry(entries, visit) { for (const entry of entries) switch (entry.type) { case "step": case "agent": case "tool": case "mapping": case "workflow": visit(entry); break; case "parallel": case "conditional": entry.steps.forEach(visit); break; case "loop": case "foreach": visit(entry.step); break; case "sleep": case "sleepUntil": break; default: } } /** * Collect the ids of every nested workflow referenced by a stored graph. * Used by boot-time loading to hydrate stored definitions in dependency order. */ function collectNestedWorkflowIds(graph) { const out = /* @__PURE__ */ new Set(); forEachSingleStepEntry(graph, (entry) => { if (entry.type === "workflow") out.add(entry.workflowId); }); return out; } /** * Same traversal as {@link forEachSingleStepEntry} but reports each leaf's * position as a dotted path (`graph.2`, `graph.2.steps.0`, `graph.2.step`) — * the path contract shared by validation issues and the Studio draft UI. * * Accepts the wider {@link ValidatableStepFlowEntry} union so both persisted * graphs and wire-shaped authoring submissions can be walked. */ function forEachSingleStepEntryWithPath(entries, visit) { entries.forEach((entry, index) => { const path = `graph.${index}`; switch (entry.type) { case "step": case "agent": case "tool": case "mapping": case "workflow": visit(entry, path); break; case "parallel": case "conditional": entry.steps.forEach((child, childIndex) => visit(child, `${path}.steps.${childIndex}`)); break; case "loop": case "foreach": visit(entry.step, `${path}.step`); break; case "sleep": case "sleepUntil": break; default: } }); } //#endregion //#region src/workflows/stored/validate/refs.ts /** * Reference checks against a caller-supplied registry index. * * Checks are gated per kind: a kind whose key is absent from the index is * skipped entirely, so callers that cannot enumerate (say) workflows never * produce false missing-reference issues. Mis-classified references get swap * hints (agent id that is actually a registered tool, and vice versa). * * `type: 'step'` descriptors are intentionally not checked — they resolve * late against the live Mastra instance at rehydration time. */ function validateWorkflowRefs(def, index) { const issues = []; forEachSingleStepEntryWithPath(def.graph, (entry, path) => { switch (entry.type) { case "agent": if (!index.agents || index.agents[entry.agentId]) return; issues.push({ code: "missing-reference", path: `${path}.agentId`, message: index.tools?.[entry.agentId] ? `Step "${entry.id}" declares { type: "agent", agentId: "${entry.agentId}" } but "${entry.agentId}" is a registered TOOL, not an agent. Change this entry to { type: "tool", toolId: "${entry.agentId}" }.` : `Step "${entry.id}" declares agentId "${entry.agentId}" which is not a registered agent.` }); return; case "tool": if (!index.tools || index.tools[entry.toolId]) return; issues.push({ code: "missing-reference", path: `${path}.toolId`, message: index.agents?.[entry.toolId] ? `Step "${entry.id}" declares { type: "tool", toolId: "${entry.toolId}" } but "${entry.toolId}" is a registered AGENT, not a tool. Change this entry to { type: "agent", agentId: "${entry.toolId}" }.` : `Step "${entry.id}" declares toolId "${entry.toolId}" which is not a registered tool.` }); return; case "workflow": if (entry.workflowId === def.id) return; if (!index.workflows || index.workflows[entry.workflowId]) return; issues.push({ code: "missing-reference", path: `${path}.workflowId`, message: `Step "${entry.id}" declares workflowId "${entry.workflowId}" which is not a registered workflow.` }); return; default: return; } }); return issues; } //#endregion //#region src/workflows/stored/validate/types.ts /** Step id of a single-step (leaf) entry; `step` descriptors carry theirs nested. */ function leafEntryId(entry) { return entry.type === "step" ? entry.step.id : entry.id; } //#endregion //#region src/workflows/stored/validate/repair-actions.ts function inputSchemaOf$1(entry, index) { switch (entry.type) { case "agent": return index.agents?.[entry.agentId]?.inputSchema ?? { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }; case "tool": return index.tools?.[entry.toolId]?.inputSchema; case "workflow": return index.workflows?.[entry.workflowId]?.inputSchema; case "mapping": case "step": return; } } function entryAtPath(def, path) { const match = /^graph\.(\d+)(?:\.(steps)\.(\d+)|\.(step))?/.exec(path); if (!match) return void 0; const entry = def.graph[Number(match[1])]; if (!entry) return void 0; if (match[2] === "steps" && (entry.type === "parallel" || entry.type === "conditional")) return entry.steps[Number(match[3])]; if (match[4] === "step" && (entry.type === "foreach" || entry.type === "loop")) return entry.step; if (entry.type === "agent" || entry.type === "tool" || entry.type === "workflow" || entry.type === "mapping" || entry.type === "step") return entry; } function precedingSourceIds(def, targetIndex) { const ids = []; def.graph.slice(0, targetIndex).forEach((entry) => { if (entry.type === "parallel" || entry.type === "conditional") entry.steps.forEach((child) => { const childId = leafEntryId(child); if (childId) ids.push(childId); }); else if (entry.type === "foreach" || entry.type === "loop") { const childId = leafEntryId(entry.step); if (childId) ids.push(childId); } if (entry.type === "parallel" || entry.type === "conditional" || entry.type === "foreach" || entry.type === "loop") return; const id = "id" in entry && entry.id ? entry.id : entry.type === "step" ? entry.step.id : void 0; if (id) ids.push(id); }); return ids; } function legalSources(def, targetIndex, expectedSchema, stepOutputs) { return [{ source: { initData: true, path: "" }, schema: def.inputSchema, compatibility: schemaCompatibility(def.inputSchema, expectedSchema) }, ...precedingSourceIds(def, targetIndex).map((stepId) => { const schema = stepOutputs.get(stepId); return { source: { step: stepId, path: "" }, ...schema ? { schema } : {}, compatibility: schemaCompatibility(schema, expectedSchema) }; })].filter((source) => source.compatibility !== "incompatible"); } function addWorkflowValidationRepairActions(def, index, issues, stepOutputs, entryInputs, finalOutput) { return issues.map((issue) => { const graphMatch = /^graph\.(\d+)/.exec(issue.path); const targetIndex = graphMatch ? Number(graphMatch[1]) : def.graph.length; const entry = entryAtPath(def, issue.path); const entryId = entry ? leafEntryId(entry) : void 0; let repair; if (issue.code === "incompatible-schema") { const containerEntry = graphMatch ? def.graph[targetIndex] : void 0; const foreachChildInput = containerEntry?.type === "foreach" ? inputSchemaOf$1(containerEntry.step, index) : void 0; const expectedSchema = containerEntry?.type === "foreach" ? { type: "array", ...foreachChildInput ? { items: foreachChildInput } : {} } : entry ? inputSchemaOf$1(entry, index) : def.outputSchema; const actualSchema = issue.path === "outputSchema" ? finalOutput : entryInputs.get(issue.path) ?? (targetIndex === 0 ? def.inputSchema : void 0); repair = { issueCode: issue.code, path: issue.path, ...entryId ? { entryId } : {}, ...expectedSchema ? { expectedSchema } : {}, ...actualSchema ? { actualSchema } : {}, legalSources: legalSources(def, targetIndex, expectedSchema, stepOutputs), operation: containerEntry?.type === "foreach" ? "update-workflow-step" : issue.path === "outputSchema" ? "insert-workflow-mapping-after" : "insert-workflow-mapping-before", arguments: entryId ? { targetStepId: entryId } : { targetPath: issue.path }, blocksCheckpoint: false, blocksFinalize: true }; } else if (issue.code === "invalid-map-config" || issue.code === "invalid-map-reference") { const destinationField = /\.mapConfig\.([^\.]+)/.exec(issue.path)?.[1]; repair = { issueCode: issue.code, path: issue.path, ...entryId ? { entryId } : {}, ...destinationField ? { destinationField } : {}, legalSources: legalSources(def, targetIndex, void 0, stepOutputs), operation: "set-workflow-mapping-source", arguments: { ...entryId ? { mappingStepId: entryId } : {}, ...destinationField ? { field: destinationField } : {} }, blocksCheckpoint: false, blocksFinalize: true }; } else if (issue.code === "invalid-predicate-reference") repair = { issueCode: issue.code, path: issue.path, ...entryId ? { childId: entryId } : {}, operation: "set-workflow-predicate", arguments: { predicatePath: issue.path }, blocksCheckpoint: false, blocksFinalize: true }; else if (issue.code === "missing-reference") repair = { issueCode: issue.code, path: issue.path, ...entryId ? { entryId } : {}, operation: "update-workflow-step", arguments: entryId ? { stepId: entryId } : { targetPath: issue.path }, blocksCheckpoint: false, blocksFinalize: true }; else if (issue.code === "invalid-map-placement") repair = { issueCode: issue.code, path: issue.path, ...entryId ? { childId: entryId } : {}, operation: "remove-workflow-step", arguments: entryId ? { stepId: entryId } : { targetPath: issue.path }, blocksCheckpoint: false, blocksFinalize: true }; return repair ? { ...issue, repair } : issue; }); } //#endregion //#region src/workflows/stored/validate/schema-flow.ts /** Agents accept `{ prompt }` unless the registry says otherwise. */ const agentInputSchema = { type: "object", properties: { prompt: { type: "string" } }, required: ["prompt"] }; /** * An agent entry without declared structured output returns exactly * `{ text }` at runtime (`runAgentEntry`), so invented output paths like * `.response` are provably wrong rather than unknown. */ const agentTextOutputSchema = { type: "object", properties: { text: { type: "string" } }, required: ["text"] }; function inputSchemaOf(entry, index) { switch (entry.type) { case "agent": return index.agents?.[entry.agentId]?.inputSchema ?? agentInputSchema; case "tool": return index.tools?.[entry.toolId]?.inputSchema; case "workflow": return index.workflows?.[entry.workflowId]?.inputSchema; case "mapping": case "step": return; } } function outputSchemaOf(entry, index) { switch (entry.type) { case "agent": return entry.outputSchema ?? index.agents?.[entry.agentId]?.outputSchema ?? agentTextOutputSchema; case "tool": return index.tools?.[entry.toolId]?.outputSchema; case "workflow": return index.workflows?.[entry.workflowId]?.outputSchema; case "mapping": case "step": return; } } function validatePredicate(predicate, path, context) { const issues = []; const validatePath = (rawPath, issuePath) => { if (!isCanonicalMappingPath(rawPath)) { issues.push({ code: "invalid-predicate-reference", path: issuePath, message: "Predicate paths must use plain dotted segments rooted at initData, inputData, stepResults, or state." }); return; } const [root, ...segments] = rawPath.split("."); let schema; let schemaPath = segments.join("."); if (root === "initData") schema = context.initData; else if (root === "inputData") schema = context.inputData; else if (root === "state") schema = context.state; else if (root === "stepResults") { const stepId = segments.shift(); if (!stepId || !context.stepResults.has(stepId)) { issues.push({ code: "invalid-predicate-reference", path: issuePath, message: stepId ? `Predicate step result "${stepId}" must reference a preceding top-level step.` : "Predicate stepResults paths must include a preceding top-level step id." }); return; } schema = context.stepResults.get(stepId); schemaPath = segments.join("."); } else { issues.push({ code: "invalid-predicate-reference", path: issuePath, message: "Predicate paths must be rooted at initData, inputData, stepResults, or state." }); return; } if (schemaPath && isRecord(schema) && typeof schema.type === "string" && !schemaAtPath(schema, schemaPath)) issues.push({ code: "invalid-predicate-reference", path: issuePath, message: `Predicate path "${rawPath}" does not exist in the known schema.` }); }; const validateRef = (ref, refPath) => { if ("path" in ref) validatePath(ref.path, `${refPath}.path`); }; switch (predicate.op) { case "and": case "or": predicate.args.forEach((arg, index) => issues.push(...validatePredicate(arg, `${path}.args.${index}`, context))); break; case "not": issues.push(...validatePredicate(predicate.arg, `${path}.arg`, context)); break; case "exists": case "notExists": validatePath(predicate.path, `${path}.path`); break; case "truthy": case "falsy": validateRef(predicate.value, `${path}.value`); break; case "in": case "notIn": validateRef(predicate.value, `${path}.value`); break; default: validateRef(predicate.left, `${path}.left`); validateRef(predicate.right, `${path}.right`); } return issues; } function inferGraphSchemas(def, index) { const issues = []; const stepOutputs = /* @__PURE__ */ new Map(); const entryInputs = /* @__PURE__ */ new Map(); /** Evaluates one leaf entry: checks its input against `incoming`, returns its output. */ const evalLeaf = (entry, path, incoming, container) => { entryInputs.set(path, incoming); if (entry.type === "mapping") { if (container) return void 0; const analysis = analyzeMapConfig(entry.mapConfig, { path, availableOutputs: stepOutputs, inputSchema: def.inputSchema, requestContextSchema: def.requestContextSchema }); issues.push(...analysis.issues); return analysis.outputSchema; } if (entry.type === "step") return void 0; if (schemaCompatibility(incoming, inputSchemaOf(entry, index)) === "incompatible") issues.push({ code: "incompatible-schema", path, message: "Step input is incompatible with the preceding workflow output." }); return outputSchemaOf(entry, index); }; let current = def.inputSchema; def.graph.forEach((entry, entryIndex) => { const path = `graph.${entryIndex}`; switch (entry.type) { case "step": case "agent": case "tool": case "mapping": case "workflow": current = evalLeaf(entry, path, current, false); break; case "sleep": case "sleepUntil": break; case "parallel": case "conditional": { const incoming = current; if (entry.type === "conditional") entry.predicates?.forEach((predicate, predicateIndex) => { if (!predicate) return; issues.push(...validatePredicate(predicate, `${path}.predicates.${predicateIndex}`, { initData: def.inputSchema, inputData: incoming, state: def.stateSchema, stepResults: stepOutputs })); }); const properties = {}; entry.steps.forEach((child, childIndex) => { const output = evalLeaf(child, `${path}.steps.${childIndex}`, incoming, true); const childId = leafEntryId(child); if (childId) { stepOutputs.set(childId, output); if (output) properties[childId] = output; } }); current = { type: "object", properties, ...entry.type === "parallel" ? { required: Object.keys(properties) } : {} }; break; } case "foreach": { const incoming = current; if (isRecord(incoming) && typeof incoming.type === "string" && incoming.type !== "array") issues.push({ code: "incompatible-schema", path, message: "Foreach input must be a raw array. A mapping step cannot produce one — mappings always build an object — so the preceding step (or the workflow inputSchema itself) must already be an array of the child input." }); const items = isRecord(incoming?.items) ? incoming.items : void 0; const output = evalLeaf(entry.step, `${path}.step`, items, true); const childId = leafEntryId(entry.step); if (childId) stepOutputs.set(childId, output); current = output ? { type: "array", items: output } : output; break; } case "loop": { const output = evalLeaf(entry.step, `${path}.step`, current, true); const stepId = leafEntryId(entry.step); if (stepId) stepOutputs.set(stepId, output); if (entry.predicate) { const loopStepOutputs = new Map(stepOutputs); issues.push(...validatePredicate(entry.predicate, `${path}.predicate`, { initData: def.inputSchema, inputData: output, state: def.stateSchema, stepResults: loopStepOutputs })); } if (schemaCompatibility(output, inputSchemaOf(entry.step, index)) === "incompatible") issues.push({ code: "incompatible-schema", path: `${path}.step`, message: "Loop step output is incompatible with its input for a subsequent iteration." }); current = output; break; } default: } const id = "id" in entry && entry.id ? entry.id : entry.type === "step" ? entry.step.id : void 0; if (id) stepOutputs.set(id, current); }); if (schemaCompatibility(current, def.outputSchema) === "incompatible") issues.push({ code: "incompatible-schema", path: "outputSchema", message: "Workflow output schema is incompatible with the final step output." }); return { stepOutputs, entryInputs, finalOutput: current, issues }; } //#endregion //#region src/workflows/stored/validate/schemas.ts /** * JSON-Schema keyword checks: every schema embedded in the definition must be * convertible by `jsonSchemaToZod` (no oneOf/anyOf/allOf/not/$ref/ * patternProperties/discriminator). Covers the four top-level schemas plus * each `agent.outputSchema` reachable through containers. */ function validateWorkflowSchemas(def) { const issues = []; const check = (schema, path, label) => { const result = validateStorableJsonSchema(schema); if (result.ok) return; issues.push({ code: "unsupported-schema-keyword", path, message: `${label} uses JSON Schema keyword(s) jsonSchemaToZod cannot convert: ${result.unsupported.join(", ")}. Simplify the schema (or extend the converter).` }); }; check(def.inputSchema, "inputSchema", "inputSchema"); check(def.outputSchema, "outputSchema", "outputSchema"); if (def.stateSchema) check(def.stateSchema, "stateSchema", "stateSchema"); if (def.requestContextSchema) check(def.requestContextSchema, "requestContextSchema", "requestContextSchema"); forEachSingleStepEntryWithPath(def.graph, (entry, path) => { if (entry.type === "agent" && entry.outputSchema) check(entry.outputSchema, `${path}.outputSchema`, `step "${entry.id}" outputSchema`); }); return issues; } //#endregion //#region src/workflows/stored/validate/structure.ts /** * Context-free structural rules: everything that can be decided from the * definition alone — ids, duplicates, entry placement, container arity, * declarative-predicate presence, nested-workflow identity, self-cycles. */ const TOP_LEVEL_PATH = /^graph\.\d+$/; function validateWorkflowStructure(def) { const issues = []; if (def.graph.length === 0) issues.push({ code: "empty-graph", path: "graph", message: "Workflow graph must contain at least one step." }); const seenIds = /* @__PURE__ */ new Set(); forEachSingleStepEntryWithPath(def.graph, (entry, path) => { const id = leafEntryId(entry); const idPath = entry.type === "step" ? `${path}.step.id` : `${path}.id`; if (!id) issues.push({ code: "missing-step-id", path: idPath, message: "Step id is required." }); else if (seenIds.has(id)) issues.push({ code: "duplicate-step-id", path: idPath, message: `Step id "${id}" is duplicated.` }); else seenIds.add(id); if (entry.type === "mapping" && !TOP_LEVEL_PATH.test(path)) issues.push({ code: "invalid-map-placement", path, message: "Persisted mapping steps must be top-level workflow entries." }); if (entry.type === "workflow") { if (entry.workflowId === def.id) issues.push({ code: "self-reference", path: `${path}.workflowId`, message: `Step "${entry.id}" declares { type: "workflow", workflowId: "${entry.workflowId}" } which refers to itself. Nested workflow cycles are not allowed.` }); } }); def.graph.forEach((entry, index) => { const path = `graph.${index}`; switch (entry.type) { case "parallel": case "conditional": if (entry.steps.length === 0) issues.push({ code: entry.type === "parallel" ? "invalid-parallel" : "invalid-conditional", path: `${path}.steps`, message: `${entry.type} steps cannot be empty.` }); if (entry.type === "conditional") if (!entry.predicates) issues.push({ code: "invalid-conditional", path, message: "Conditional entries must use declarative predicates." }); else { if (entry.steps.length !== entry.predicates.length) issues.push({ code: "invalid-conditional", path, message: "Conditional steps and predicates must be aligned." }); entry.predicates.forEach((predicate, predicateIndex) => { if (predicate === null) issues.push({ code: "invalid-conditional", path: `${path}.predicates.${predicateIndex}`, message: "Conditional entries must use declarative predicates." }); }); } return; case "loop": if (!entry.predicate) issues.push({ code: "invalid-loop", path, message: "Loop entries must use a declarative predicate." }); return; case "foreach": if (entry.opts?.concurrency !== void 0 && entry.opts.concurrency < 1) issues.push({ code: "invalid-foreach", path: `${path}.opts.concurrency`, message: "Concurrency must be positive." }); return; default: return; } }); return issues; } //#endregion //#region src/workflows/stored/validate/index.ts /** * The one stored-workflow validation domain. * * `validateStoredWorkflow` is the collect-mode core every surface shares: * structure, JSON-Schema keywords, registry references, and schema-flow * analysis, each emitting `{ code, path, message }` issues. UIs consume the * array; the save path throws via `assertValidStoredWorkflow`. */ /** * Runs every check and returns the collected issues (empty = valid). * * The registry index gates context-dependent checks: reference checks only * run for kinds present in the index, and schema-flow compatibility only * proves mismatches where schemas are known. */ function validateStoredWorkflow(def, index = {}) { const inference = inferGraphSchemas(def, index); return addWorkflowValidationRepairActions(def, index, [ ...validateWorkflowStructure(def), ...validateWorkflowSchemas(def), ...validateWorkflowRefs(def, index), ...inference.issues ], inference.stepOutputs, inference.entryInputs, inference.finalOutput); } /** Throwing presentation of {@link validateStoredWorkflow} for the save path. */ function assertValidStoredWorkflow(def, index = {}) { const issues = validateStoredWorkflow(def, index); if (issues.length === 0) return; const details = issues.map((issue) => `- [${issue.code}] ${issue.path}: ${issue.message}`).join("\n"); throw new Error(`Stored workflow "${def.id}" failed validation with ${issues.length} issue(s):\n${details}`); } //#endregion export { inferGraphSchemas as a, forEachSingleStepEntry as c, analyzeMapConfig as d, p