akm-cli
Version:
akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur
1,323 lines • 61.3 kB
JavaScript
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/**
* Unified workflow markdown parser (workflow-format-unification).
*
* One frontmatter+body parser replaces the two prior format grammars: the
* classic `# Workflow:` / `## Step:` markdown parser, and the YAML program's
* field validator. Composition over invention: the frontmatter block is
* parsed with the `yaml` package's `parseDocument` + `LineCounter` (best-effort
* per-key line anchoring), the body's heading list with `parseMarkdownToc`
* (already fence-aware) — both already in the codebase. The parser
* accumulates `WorkflowError`s rather than throwing, and emits a compiled
* {@link WorkflowPlan} directly (`plan.ts`).
*
* Frontmatter carries the orchestration graph (params/defaults/budget/steps);
* the body carries per-step prose, joined to the graph by step id. Body rules
*, exactly three:
*
* 1. Every level-2 heading must be `## <step-id>` for a DECLARED step,
* exactly (fenced code blocks are skipped when scanning for headings).
* 2. A unit/map step MUST have a section (its instructions, byte-exact to
* the next H2 or EOF); a route step MAY. Everything before the first H2
* is free preamble.
* 3. Inside a step section, an optional `### gate` sub-heading starts the
* step's gate rubric (to the section end) — the format's single
* reserved marker. Omitted or empty rubric text skips validation.
*
* Prose (instructions, gate rubrics, preamble) is NEVER templated or scanned
* for reference syntax — it reaches the dispatched unit byte-exact. Only
* three whole-value frontmatter positions carry the closed reference grammar
* (`program/expressions.ts`): `map.over`, `route.input`, `inputs[]`.
*/
import { LineCounter, parseDocument } from "yaml";
import { bundleRefToString, parseBundleRef } from "../core/asset/asset-ref.js";
import { parseFrontmatterBlock } from "../core/asset/frontmatter.js";
import { parseMarkdownToc } from "../core/asset/markdown.js";
import { compareCodePoints, isContainedRelativePath, isRecord } from "../core/common.js";
import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
import { checkJsonSchemaDefinition, JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS } from "../core/json-schema.js";
import { INPUT_NAME_PATTERN } from "../execution/input-contract.js";
import { WORKFLOW_PLAN_VERSION, } from "./plan.js";
import { parseReference } from "./program/expressions.js";
import { jsonBytes, utf8Bytes, WORKFLOW_ENGINE_NAME_PATTERN, WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_ENGINE_NAME_LENGTH, WORKFLOW_MAX_EXTRA_PARAMS_BYTES, WORKFLOW_MAX_INSTRUCTION_BYTES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_SOURCE_BYTES, WORKFLOW_MAX_TIMEOUT_MS, } from "./resource-limits.js";
// ── The Markdown grammar's vocabulary (pinned against schemas/akm-workflow.json) ──
export const PROGRAM_REDUCERS = ["collect", "vote"];
export const PROGRAM_ON_ERROR = ["fail", "continue"];
export const PROGRAM_ISOLATION_KINDS = ["none", "worktree"];
/** `retry.on`: exactly the persisted `AgentFailureReason` taxonomy (the `satisfies` fails typecheck on drift). */
const RETRY_REASON_SET = {
timeout: true,
spawn_failed: true,
non_zero_exit: true,
parse_error: true,
cooldown: true,
llm_rate_limit: true,
llm_content_filter: true,
llm_invalid_json: true,
content_policy_reject: true,
unsupported_type: true,
no_change: true,
quality_rejected: true,
aborted: true,
};
export const PROGRAM_RETRY_REASONS = Object.keys(RETRY_REASON_SET);
/**
* Step ids are exactly the `<ident>` grammar `steps.<id>.output` references
* accept: no dots, so every step is addressable and `<stepId>.gate` can never
* collide with a real step id.
*/
export const PROGRAM_STEP_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
/** Param (and output) names: plain `params.<ident>`-addressable identifiers, shared with task inputs. */
export const PROGRAM_PARAM_NAME_PATTERN = INPUT_NAME_PATTERN;
/** Envelope keys every AKM markdown asset carries ($ref'd from schemas/akm-asset-envelope.json). */
const ENVELOPE_KEYS = [
"type",
"description",
"tags",
"when_to_use",
"xrefs",
"updated",
"timestamp",
"generated",
"verified",
"provenance",
"status",
"stale_after",
];
const WORKFLOW_KEYS = ["params", "outputs", "defaults", "budget", "steps"];
const TOP_LEVEL_KEYS = [...ENVELOPE_KEYS, ...WORKFLOW_KEYS];
const DEFAULTS_KEYS = ["engine", "model", "timeout", "on_error", "llm"];
const BUDGET_KEYS = ["max_tokens", "max_units"];
const STEP_KEYS = ["id", "unit", "map", "route", "inputs", "output", "gate"];
const UNIT_KEYS = ["exec", "engine", "model", "llm", "timeout", "retry", "on_error", "output", "env", "isolation"];
const EXEC_KEYS = ["command", "cwd", "pass_env"];
/** Unit keys that name an ENGINE dispatch and therefore cannot appear beside `exec:`. */
const UNIT_ENGINE_KEYS = ["engine", "model", "llm"];
const MAP_KEYS = ["over", "concurrency", "reducer", "unit"];
const ROUTE_KEYS = ["input", "when", "default"];
const RETRY_KEYS = ["max", "on"];
const GATE_KEYS = ["max_loops"];
const ROUTE_BRANCH_KEYS = ["match", "step"];
/** Closed key set of one `outputs:` entry — mirrors `params:`'s bare-schema shape, plus `from`. */
const OUTPUT_ENTRY_KEYS = ["from", "schema"];
const ACTOR_STAMP_KEYS = ["by", "at"];
const TIMEOUT_VALUE = /^(\d+)(ms|s|m)?$/;
const TIMEOUT_HINT = `Use "<n>ms", "<n>s", "<n>m" (e.g. "10m"), or "none"`;
const LIFECYCLE_STATUSES = new Set(["draft", "stable", "deprecated"]);
export function parseWorkflow(markdown, source) {
if (utf8Bytes(markdown) > WORKFLOW_MAX_SOURCE_BYTES) {
return {
ok: false,
errors: [{ line: 1, message: "Workflow source exceeds the 1 MiB resource limit." }],
};
}
const errors = [];
const path = source.path;
const lines = markdown.split(/\r?\n/);
const totalLines = lines.length;
const fmBlock = parseFrontmatterBlock(markdown);
if (!fmBlock) {
return {
ok: false,
errors: [
{
line: 1,
message: `Workflow markdown must start with a YAML frontmatter block ("---" ... "---") declaring at least ` +
`a non-empty "steps" list.`,
},
],
};
}
// The frontmatter substring always starts right after the opening "---"
// line (exactly one line), so a yaml LineCounter position over just this
// substring is always exactly one less than the real file line.
const lineOffset = 1;
const frontmatterEndLine = Math.max(1, fmBlock.bodyStartLine - 1);
const lineCounter = new LineCounter();
let doc;
try {
doc = parseDocument(fmBlock.frontmatter, { lineCounter });
}
catch (cause) {
return {
ok: false,
errors: [{ line: 2, message: `Workflow frontmatter is not valid YAML: ${describeError(cause)}` }],
};
}
for (const problem of doc.errors) {
const offset = Array.isArray(problem.pos) ? problem.pos[0] : 0;
errors.push({
line: Math.max(1, lineCounter.linePos(offset).line + lineOffset),
message: yamlErrorMessage(problem.message),
});
}
if (errors.length > 0)
return { ok: false, errors };
let root;
try {
root = doc.toJS();
}
catch (cause) {
return { ok: false, errors: [{ line: 2, message: `YAML expansion failed: ${describeError(cause)}` }] };
}
const lineAt = (p) => {
for (let depth = p.length; depth >= 0; depth--) {
const node = depth === 0 ? doc.contents : doc.getIn(p.slice(0, depth), true);
const range = node?.range;
if (range)
return Math.max(1, lineCounter.linePos(range[0]).line + lineOffset);
}
return 2;
};
const ctx = {
filePath: path,
errors,
...(source.validateExecCwd ? { validateExecCwd: source.validateExecCwd } : {}),
lineAt,
lineAtOffset: (offset) => Math.max(1, lineCounter.linePos(offset).line + lineOffset),
nodeAt: (p) => (p.length === 0 ? doc.contents : doc.getIn(p, true)),
refAt: (p) => {
for (let depth = p.length; depth >= 0; depth--) {
const node = depth === 0 ? doc.contents : doc.getIn(p.slice(0, depth), true);
const range = node?.range;
if (range) {
const start = Math.max(1, lineCounter.linePos(range[0]).line + lineOffset);
const end = Math.max(start, lineCounter.linePos(Math.max(range[0], range[1] - 1)).line + lineOffset);
return { path, start, end };
}
}
return { path, start: frontmatterEndLine, end: frontmatterEndLine };
},
err: (p, message) => errors.push({ line: lineAt(p), message }),
errAtLine: (line, message) => errors.push({ line, message }),
};
if (root === null || root === undefined)
root = {};
if (!isRecord(root)) {
return {
ok: false,
errors: [{ line: 2, message: `Workflow frontmatter must be a YAML mapping (key: value pairs).` }],
};
}
checkUnknownKeys(ctx, root, [], TOP_LEVEL_KEYS, "workflow frontmatter");
checkEnvelopeFields(ctx, root, frontmatterEndLine);
const description = typeof root.description === "string" ? root.description : undefined;
readTags(ctx, root.tags, frontmatterEndLine);
const params = parseParams(ctx, root.params);
const outputs = parseOutputs(ctx, root.outputs);
const defaults = parseDefaults(ctx, root.defaults);
const budget = parseBudget(ctx, root.budget);
const parsedSteps = parseSteps(ctx, root.steps);
// ── Body binding ────────────────────────────────────────────────────────
const toc = parseMarkdownToc(markdown);
const declaredIds = new Set(parsedSteps.map((s) => s.id));
const { sections, preamble } = bindStepSections(toc.headings, lines, fmBlock.bodyStartLine, totalLines, path, declaredIds, errors);
const steps = parsedSteps.map((step, index) => {
const section = sections.get(step.id);
if (!section) {
if (step.route === undefined) {
errors.push({
line: step.source.start,
message: `Step "${step.id}" is a unit/map step and must have a "## ${step.id}" body section with its instructions.`,
});
}
}
else if (step.route === undefined && !section.instructions) {
errors.push({
line: section.headingLine,
message: `Step "${step.id}" section ("## ${step.id}") is empty. Add the step's instructions below the heading.`,
});
}
for (const [block, what] of [
[section?.instructions, "instructions exceed"],
[section?.gateRubric, "gate rubric exceeds"],
]) {
if (block && utf8Bytes(block.text) > WORKFLOW_MAX_INSTRUCTION_BYTES) {
errors.push({ line: block.source.start, message: `Step "${step.id}" ${what} the 256 KiB resource limit.` });
}
}
const rubric = section?.gateRubric?.text;
return {
stepId: step.id,
title: step.id,
sequenceIndex: index,
spec: stepSpec(step, section),
...(step.route
? {
route: {
input: step.route.input,
when: Object.fromEntries(step.route.branches.map((branch) => [branch.match, branch.stepId])),
...(step.route.defaultStepId !== undefined ? { defaultStepId: step.route.defaultStepId } : {}),
},
}
: {}),
...(step.output !== undefined ? { outputSchema: step.output } : {}),
gate: {
kind: "gate",
id: `${step.id}.gate`,
stepId: step.id,
criteria: rubric?.trim() ? [rubric] : [],
maxLoops: step.gate?.maxLoops ?? 1,
frozenJudge: null,
},
};
});
checkCanonicalXrefs(root.xrefs, frontmatterEndLine, errors);
if (errors.length > 0)
return { ok: false, errors: sortErrors(errors) };
const paramNames = params ? Object.keys(params) : [];
const plan = {
irVersion: WORKFLOW_PLAN_VERSION,
title: source.title ?? path.replace(/^.*[\\/]/, "").replace(/\.[^.]*$/, ""),
...(params && paramNames.length > 0 ? { params: paramNames, paramSchemas: params } : {}),
...(outputs ? { outputs: sortedByName(outputs) } : {}),
...(budget ? { budget } : {}),
...(defaults ? { defaults } : {}),
...(description ? { description } : {}),
...(preamble ? { preamble } : {}),
steps,
};
return { ok: true, plan };
}
/** The authored step as the plan carries it. A prose step is inline `akm/command` literal content. */
function stepSpec(step, section) {
const prose = section?.instructions?.text;
if (step.route)
return { ...(prose?.trim() ? { instructions: prose } : {}), source: step.source };
const dispatchUnit = step.map?.unit ?? step.unit;
const { exec, source: _unitSource, ...unit } = dispatchUnit ?? { source: step.source };
return {
...(exec
? { exec: { ...exec }, ...(prose?.trim() ? { instructions: prose } : {}) }
: { uses: "akm/command", commandMode: "literal", with: { content: prose ?? "" } }),
...(Object.keys(unit).length > 0 ? { unit } : {}),
...(step.map
? {
map: {
over: step.map.over,
...(step.map.concurrency !== undefined ? { concurrency: step.map.concurrency } : {}),
...(step.map.reducer !== undefined ? { reducer: step.map.reducer } : {}),
},
}
: {}),
...(step.inputs ? { inputs: [...step.inputs] } : {}),
source: step.source,
};
}
/** `outputs:` in canonical wire order (code-point ascending by name). */
function sortedByName(entries) {
return Object.fromEntries(Object.keys(entries)
.sort(compareCodePoints)
.map((name) => [name, entries[name]]));
}
/** `xrefs` entries must be canonical asset refs. */
function checkCanonicalXrefs(value, line, errors) {
if (!Array.isArray(value))
return; // shape already flagged by checkXrefs
for (const ref of value) {
try {
if (typeof ref !== "string")
throw new Error("non-canonical ref");
const parsed = parseBundleRef(ref);
if (parsed.conceptId.includes(":") || ref !== bundleRefToString(parsed))
throw new Error("non-canonical ref");
}
catch {
errors.push({
line,
message: `Workflow frontmatter "xrefs" contains an invalid or non-canonical ref: ${String(ref)}.`,
});
}
}
}
function bindStepSections(headings, lines, bodyStartLine, totalLines, path, declaredIds, errors) {
const sections = new Map();
const h2s = headings.filter((h) => h.level === 2);
const firstH2Line = h2s[0]?.line;
const preambleRaw = firstH2Line
? sliceLines(lines, bodyStartLine, firstH2Line - 1).trim()
: sliceLines(lines, bodyStartLine, totalLines).trim();
for (let i = 0; i < headings.length; i++) {
const h = headings[i];
if (h.level !== 2)
continue;
if (!declaredIds.has(h.text)) {
errors.push({
line: h.line,
message: `Unexpected level-2 heading "## ${h.text}" on line ${h.line} — no step "${h.text}" is declared in frontmatter "steps:". Level-2 headings must exactly match a declared step id. To document something that is not a step, use a level-3 heading.`,
});
continue;
}
if (sections.has(h.text)) {
errors.push({
line: h.line,
message: `Step "${h.text}" has more than one "## ${h.text}" section (first on line ${sections.get(h.text).headingLine}). Keep only one.`,
});
continue;
}
const sectionEnd = findNextHeadingAtOrAboveLevel(headings, i, 2, totalLines);
const gate = findGateSubsection(headings, i, sectionEnd, path, h.text, errors);
const instructionsEnd = gate ? gate.headingLine - 1 : sectionEnd;
const instructionsText = sliceProseLines(lines, h.line + 1, instructionsEnd);
const section = { headingLine: h.line };
if (instructionsText) {
section.instructions = { text: instructionsText, source: { path, start: h.line + 1, end: instructionsEnd } };
}
if (gate) {
const gateText = sliceProseLines(lines, gate.bodyStart, gate.bodyEnd);
if (gateText) {
section.gateRubric = { text: gateText, source: { path, start: gate.bodyStart, end: gate.bodyEnd } };
}
}
sections.set(h.text, section);
}
return { sections, preamble: preambleRaw || undefined };
}
/** Find a step section's `### gate` sub-heading, if any — the format's single reserved marker. */
function findGateSubsection(headings, stepHeadingIndex, sectionEnd, path, stepId, errors) {
let found;
for (let j = stepHeadingIndex + 1; j < headings.length; j++) {
const h = headings[j];
if (h.line > sectionEnd)
break;
if (h.level <= 2)
break; // next step / preamble heading — section already bounded here
if (h.level !== 3 || h.text !== "gate")
continue;
if (found) {
errors.push({
line: h.line,
message: `Step "${stepId}" has more than one "### gate" sub-heading (first on line ${found.headingLine}). Keep only one.`,
});
continue;
}
found = { headingLine: h.line, bodyStart: h.line + 1, bodyEnd: sectionEnd };
}
void path;
return found;
}
function findNextHeadingAtOrAboveLevel(headings, fromIndex, level, totalLines) {
for (let i = fromIndex + 1; i < headings.length; i++) {
if (headings[i].level <= level)
return headings[i].line - 1;
}
return totalLines;
}
function sliceLines(lines, startLineInclusive, endLineInclusive) {
if (endLineInclusive < startLineInclusive)
return "";
const s = Math.max(1, startLineInclusive);
const e = Math.min(endLineInclusive, lines.length);
return lines.slice(s - 1, e).join("\n");
}
function sliceProseLines(lines, startLineInclusive, endLineInclusive) {
let start = Math.max(1, startLineInclusive);
let end = Math.min(endLineInclusive, lines.length);
while (start <= end && /^\s*$/.test(lines[start - 1]))
start++;
while (end >= start && /^\s*$/.test(lines[end - 1]))
end--;
return sliceLines(lines, start, end);
}
// ---------------------------------------------------------------------------
// Envelope fields
// ---------------------------------------------------------------------------
function checkEnvelopeFields(ctx, root, fmEndLine) {
if (root.type !== undefined && root.type !== "workflow") {
ctx.err(["type"], `Workflow frontmatter "type" must be "workflow" (got ${JSON.stringify(root.type)}).`);
}
if (root.when_to_use !== undefined && typeof root.when_to_use !== "string") {
ctx.err(["when_to_use"], `Workflow frontmatter "when_to_use" must be a string.`);
}
if (root.description !== undefined && typeof root.description !== "string") {
ctx.err(["description"], `Workflow frontmatter "description" must be a string.`);
}
checkXrefs(ctx, root.xrefs, fmEndLine);
if (root.updated !== undefined && typeof root.updated !== "string") {
ctx.err(["updated"], `Workflow frontmatter "updated" must be a string.`);
}
if (root.timestamp !== undefined && typeof root.timestamp !== "string") {
ctx.err(["timestamp"], `Workflow frontmatter "timestamp" must be a string.`);
}
checkActorStamp(ctx, root.generated, ["generated"], `"generated"`);
if (root.verified !== undefined) {
if (Array.isArray(root.verified)) {
root.verified.forEach((entry, i) => {
checkActorStamp(ctx, entry, ["verified", i], `"verified"`);
});
}
else {
checkActorStamp(ctx, root.verified, ["verified"], `"verified"`);
}
}
if (root.provenance !== undefined && !isRecord(root.provenance)) {
ctx.err(["provenance"], `Workflow frontmatter "provenance" must be a mapping.`);
}
if (root.status !== undefined && (typeof root.status !== "string" || !LIFECYCLE_STATUSES.has(root.status))) {
ctx.err(["status"], `Workflow frontmatter "status" must be one of: draft, stable, deprecated.`);
}
if (root.stale_after !== undefined && typeof root.stale_after !== "string") {
ctx.err(["stale_after"], `Workflow frontmatter "stale_after" must be a string.`);
}
}
function checkActorStamp(ctx, value, path, label) {
if (value === undefined)
return;
if (!isRecord(value)) {
ctx.err(path, `Workflow frontmatter ${label} must be a mapping with a non-empty "by".`);
return;
}
checkUnknownKeys(ctx, value, path, ACTOR_STAMP_KEYS, `${label} actor stamp`);
if (typeof value.by !== "string" || value.by.length === 0) {
ctx.err([...path, "by"], `Workflow frontmatter ${label} must be a mapping with a non-empty "by".`);
}
if (value.at !== undefined && typeof value.at !== "string") {
ctx.err([...path, "at"], `Workflow frontmatter ${label} actor stamp "at" must be a string.`);
}
}
function readTags(ctx, value, fmEndLine) {
if (value === undefined)
return undefined;
if (!Array.isArray(value) || !value.every((tag) => typeof tag === "string" && tag.trim().length > 0)) {
ctx.errAtLine(fmEndLine, `Workflow frontmatter "tags" must be an array of non-empty strings.`);
return undefined;
}
return value.map((tag) => tag.trim());
}
function checkXrefs(ctx, value, fmEndLine) {
if (value === undefined)
return;
if (!Array.isArray(value) || !value.every((ref) => typeof ref === "string")) {
ctx.errAtLine(fmEndLine, `Workflow frontmatter "xrefs" must be an array of canonical asset refs.`);
}
}
// ---------------------------------------------------------------------------
// Top-level sections (frontmatter graph)
// ---------------------------------------------------------------------------
function parseParams(ctx, raw) {
if (raw === undefined)
return undefined;
if (!isRecord(raw)) {
ctx.err(["params"], `"params" must be a mapping of param name to a JSON Schema object (e.g. changed_files: { type: array }).`);
return undefined;
}
const params = {};
for (const [paramName, value] of Object.entries(raw)) {
if (!PROGRAM_PARAM_NAME_PATTERN.test(paramName)) {
ctx.err(["params", paramName], `Param name "${paramName}" is invalid. Use letters, digits, and underscores, starting with a letter or underscore, so "params.${paramName}" can address it.`);
continue;
}
if (!isRecord(value)) {
ctx.err(["params", paramName], `Param "${paramName}" must be a JSON Schema object (e.g. { type: string }).`);
continue;
}
if (jsonBytes(value) > WORKFLOW_MAX_SCHEMA_BYTES) {
ctx.err(["params", paramName], `Param "${paramName}" schema exceeds the 256 KiB resource limit.`);
}
checkSchemaDefinition(ctx, value, ["params", paramName], `Param "${paramName}" schema`);
params[paramName] = value;
}
return Object.keys(params).length > 0 ? params : undefined;
}
/**
* `outputs:`: named, optionally schema-validated projections
* of step artifacts, exported when the run completes. Symmetrical with
* `parseParams` above — same authoring surface, same name grammar,
* same schema-subset validator, same per-schema byte bound — but each entry
* is a STRUCTURED `{from, schema?}` mapping rather than a bare JSON Schema.
*
* `from` is validated for GRAMMAR only here: it must parse and it must be a
* `steps.<id>.output(.<seg>)*` reference (never `params.<name>` — an output
* projects a step artifact, never a param, B-07). Whether the named step is
* actually DECLARED in this document is a semantic, cross-step check left to
* `compile.ts`'s `checkWorkflowPlan`, mirroring how `inputs[]` /
* `map.over` / `route.input` already split "syntax here, semantics there".
*/
function parseOutputs(ctx, raw) {
if (raw === undefined)
return undefined;
if (!isRecord(raw)) {
ctx.err(["outputs"], `"outputs" must be a mapping of output name to { from, schema? } (e.g. report: { from: steps.summarize.output }).`);
return undefined;
}
const outputs = {};
for (const [outputName, value] of Object.entries(raw)) {
const path = ["outputs", outputName];
if (!PROGRAM_PARAM_NAME_PATTERN.test(outputName)) {
ctx.err(path, `Output name "${outputName}" is invalid. Use letters, digits, and underscores, starting with a letter or ` +
`underscore, so "steps.<child>.output.${outputName}" can address it.`);
continue;
}
if (!isRecord(value)) {
ctx.err(path, `Output "${outputName}" must be a mapping with "from" (and optional "schema").`);
continue;
}
checkUnknownKeys(ctx, value, path, OUTPUT_ENTRY_KEYS, `output "${outputName}"`);
if (typeof value.from !== "string" || value.from.trim() === "") {
ctx.err([...path, "from"], `Output "${outputName}" must declare "from": a steps.<id>.output(.<seg>)* reference.`);
continue;
}
const parsedFrom = parseReference(value.from);
if (!parsedFrom.ok) {
ctx.err([...path, "from"], `Output "${outputName}" "from": ${parsedFrom.message}`);
continue;
}
if (parsedFrom.expr.kind !== "stepOutput") {
ctx.err([...path, "from"], `Output "${outputName}" "from" must reference a step output (steps.<id>.output...), not a param — an ` +
`output projects a step artifact, never a param (got "${value.from}").`);
continue;
}
const entry = { from: value.from };
if (value.schema !== undefined) {
if (!isRecord(value.schema)) {
ctx.err([...path, "schema"], `Output "${outputName}" "schema" must be a JSON Schema object.`);
}
else {
if (jsonBytes(value.schema) > WORKFLOW_MAX_SCHEMA_BYTES) {
ctx.err([...path, "schema"], `Output "${outputName}" schema exceeds the 256 KiB resource limit.`);
}
checkSchemaDefinition(ctx, value.schema, [...path, "schema"], `Output "${outputName}" schema`);
entry.schema = value.schema;
}
}
outputs[outputName] = entry;
}
return Object.keys(outputs).length > 0 ? outputs : undefined;
}
function parseDefaults(ctx, raw) {
if (raw === undefined)
return undefined;
const path = ["defaults"];
if (!isRecord(raw)) {
ctx.err(path, `"defaults" must be a mapping with any of: ${DEFAULTS_KEYS.join(", ")}.`);
return undefined;
}
checkUnknownKeys(ctx, raw, path, DEFAULTS_KEYS, `"defaults"`);
const defaults = {};
if (raw.engine !== undefined) {
const engine = parseEngineName(ctx, raw.engine, [...path, "engine"], `"defaults.engine"`);
if (engine !== undefined)
defaults.engine = engine;
}
if (raw.model !== undefined) {
if (typeof raw.model === "string" && raw.model.trim() !== "")
defaults.model = raw.model.trim();
else
ctx.err([...path, "model"], `"defaults.model" must be a non-empty string (a model alias or exact id).`);
}
const timeoutMs = parseTimeoutField(ctx, raw.timeout, [...path, "timeout"], `"defaults.timeout"`);
if (timeoutMs !== undefined)
defaults.timeoutMs = timeoutMs;
const onError = parseEnumField(ctx, raw.on_error, [...path, "on_error"], `"defaults.on_error"`, PROGRAM_ON_ERROR);
if (onError !== undefined)
defaults.onError = onError;
const llm = parseLlmOverrides(ctx, raw.llm, [...path, "llm"], `"defaults.llm"`);
if (llm !== undefined)
defaults.llm = llm;
return Object.keys(defaults).length > 0 ? defaults : undefined;
}
function parseBudget(ctx, raw) {
if (raw === undefined)
return undefined;
const path = ["budget"];
if (!isRecord(raw)) {
ctx.err(path, `"budget" must be a mapping with any of: ${BUDGET_KEYS.join(", ")}.`);
return undefined;
}
checkUnknownKeys(ctx, raw, path, BUDGET_KEYS, `"budget"`);
const budget = {};
if (raw.max_tokens !== undefined) {
if (typeof raw.max_tokens === "number" && Number.isInteger(raw.max_tokens) && raw.max_tokens >= 1) {
budget.maxTokens = raw.max_tokens;
}
else {
ctx.err([...path, "max_tokens"], `"budget.max_tokens" must be an integer >= 1.`);
}
}
if (raw.max_units !== undefined) {
if (typeof raw.max_units === "number" && Number.isInteger(raw.max_units) && raw.max_units >= 1) {
budget.maxUnits = raw.max_units;
}
else {
ctx.err([...path, "max_units"], `"budget.max_units" must be an integer >= 1.`);
}
}
return Object.keys(budget).length > 0 ? budget : undefined;
}
function parseSteps(ctx, raw) {
if (!Array.isArray(raw) || raw.length === 0) {
ctx.err(["steps"], `"steps" is required and must be a list with at least one step.`);
return [];
}
// First pass: collect ids so route targets can be checked against ALL steps
// (including ones that fail their own validation).
const idIndex = new Map();
raw.forEach((rawStep, index) => {
if (isRecord(rawStep) && typeof rawStep.id === "string" && !idIndex.has(rawStep.id)) {
idIndex.set(rawStep.id, index);
}
});
const steps = [];
const seenIds = new Map();
const routeChecks = [];
raw.forEach((rawStep, index) => {
const path = ["steps", index];
if (!isRecord(rawStep)) {
ctx.err(path, `Step ${index + 1} must be a mapping with an "id".`);
return;
}
const label = typeof rawStep.id === "string" && rawStep.id !== "" ? `Step "${rawStep.id}"` : `Step ${index + 1}`;
checkUnknownKeys(ctx, rawStep, path, STEP_KEYS, label);
let id = "";
if (typeof rawStep.id !== "string" || rawStep.id === "") {
ctx.err([...path, "id"], `${label} requires a non-empty string "id".`);
}
else if (!PROGRAM_STEP_ID_PATTERN.test(rawStep.id)) {
ctx.err([...path, "id"], `${label} has an invalid id "${rawStep.id}". A step id cannot be referenced from steps.${rawStep.id}.output ` +
`unless it matches [A-Za-z_][A-Za-z0-9_-]* (a letter or underscore first, then letters, digits, ` +
`underscores, or dashes; no dots, no leading digit).`);
}
else {
id = rawStep.id;
const firstIndex = seenIds.get(id);
if (firstIndex !== undefined) {
ctx.err([...path, "id"], `Duplicate step id "${id}" (first used by step ${firstIndex + 1}). Step ids must be unique.`);
}
else {
seenIds.set(id, index);
}
}
const declaredKinds = ["map", "route"].filter((kind) => rawStep[kind] !== undefined);
if (declaredKinds.length > 1) {
ctx.err(path, `${label} must declare at most one of "map" or "route" (found ${declaredKinds.join(" + ")}).`);
}
const isRoute = rawStep.route !== undefined;
const isMapStep = rawStep.map !== undefined;
if (isRoute && rawStep.unit !== undefined) {
ctx.err(path, `${label} is a route step and cannot also declare "unit" (route steps dispatch no unit).`);
}
if (isMapStep && rawStep.unit !== undefined) {
ctx.err(path, `${label} is a map step; the per-item dispatch-override bag belongs at "map.unit", not top-level "unit".`);
}
if (isRoute && rawStep.inputs !== undefined) {
ctx.err(path, `${label} is a route step and cannot declare "inputs" (route steps dispatch no unit).`);
}
const unit = rawStep.unit !== undefined && !isRoute && !isMapStep
? parseUnit(ctx, rawStep.unit, [...path, "unit"], label)
: undefined;
const map = isMapStep ? parseMap(ctx, rawStep.map, [...path, "map"], label) : undefined;
const route = isRoute ? parseRoute(ctx, rawStep.route, [...path, "route"], label, index, routeChecks) : undefined;
const inputs = !isRoute ? parseInputs(ctx, rawStep.inputs, [...path, "inputs"], label) : undefined;
const output = parseSchemaObject(ctx, rawStep.output, [...path, "output"], `${label} "output"`);
const gate = rawStep.gate !== undefined ? parseGate(ctx, rawStep.gate, [...path, "gate"], label) : undefined;
const step = { id, source: ctx.refAt(path) };
if (unit)
step.unit = unit;
if (map)
step.map = map;
if (route)
step.route = route;
if (inputs)
step.inputs = inputs;
if (output !== undefined)
step.output = output;
if (gate !== undefined)
step.gate = gate;
steps.push(step);
});
// Route target post-pass: targets exist, come after the routing step, and
// never point back at it.
for (const check of routeChecks) {
const targets = [...check.branches.map((b) => ({ stepId: b.stepId, line: b.line }))];
if (check.defaultTarget)
targets.push(check.defaultTarget);
for (const target of targets) {
const targetIndex = idIndex.get(target.stepId);
if (targetIndex === undefined) {
ctx.errAtLine(target.line, `${check.stepLabel} routes to unknown step "${target.stepId}". Route targets must name a step id in this workflow.`);
}
else if (targetIndex === check.stepIndex) {
ctx.errAtLine(target.line, `${check.stepLabel} must not route to itself.`);
}
else if (targetIndex < check.stepIndex) {
ctx.errAtLine(target.line, `${check.stepLabel} routes backward to "${target.stepId}" (step ${targetIndex + 1}). Route targets must come after the routing step.`);
}
}
}
return steps;
}
// ---------------------------------------------------------------------------
// Step blocks
// ---------------------------------------------------------------------------
function parseUnit(ctx, raw, path, stepLabel) {
if (!isRecord(raw)) {
ctx.err(path, `${stepLabel} "unit" must be a mapping (a dispatch-override bag).`);
return undefined;
}
checkUnknownKeys(ctx, raw, path, UNIT_KEYS, `${stepLabel} "unit"`);
const unit = { source: ctx.refAt(path) };
if (raw.exec !== undefined) {
const exec = parseExec(ctx, raw.exec, [...path, "exec"], stepLabel);
if (exec !== undefined)
unit.exec = exec;
// An exec unit dispatches no engine call, so every engine-selection key is
// a contradiction rather than a harmless extra. Reported per key so the
// author sees exactly which line to delete.
for (const key of UNIT_ENGINE_KEYS) {
if (raw[key] === undefined)
continue;
ctx.err([...path, key], `${stepLabel} "unit" declares both "exec" and "${key}". An exec unit runs a shell command and never ` +
`reaches an engine, so "${key}" would have no effect — remove one of the two.`);
}
}
if (raw.engine !== undefined) {
const engine = parseEngineName(ctx, raw.engine, [...path, "engine"], `${stepLabel} "engine"`);
if (engine !== undefined)
unit.engine = engine;
}
if (raw.model !== undefined) {
if (typeof raw.model === "string" && raw.model.trim() !== "")
unit.model = raw.model.trim();
else
ctx.err([...path, "model"], `${stepLabel} "model" must be a non-empty string (a model alias or exact id).`);
}
const llm = parseLlmOverrides(ctx, raw.llm, [...path, "llm"], `${stepLabel} "llm"`);
if (llm !== undefined)
unit.llm = llm;
const timeoutMs = parseTimeoutField(ctx, raw.timeout, [...path, "timeout"], `${stepLabel} "timeout"`);
if (timeoutMs !== undefined)
unit.timeoutMs = timeoutMs;
const retry = parseRetry(ctx, raw.retry, [...path, "retry"], stepLabel);
if (retry !== undefined)
unit.retry = retry;
const onError = parseEnumField(ctx, raw.on_error, [...path, "on_error"], `${stepLabel} "on_error"`, PROGRAM_ON_ERROR);
if (onError !== undefined)
unit.onError = onError;
const output = parseSchemaObject(ctx, raw.output, [...path, "output"], `${stepLabel} unit "output"`);
if (output !== undefined)
unit.output = output;
if (raw.env !== undefined) {
if (Array.isArray(raw.env) && raw.env.every((entry) => typeof entry === "string" && entry.trim() !== "")) {
const envRefs = raw.env.map((entry) => entry.trim());
// Same decoder uniqueness requirement as `inputs` above: duplicates linted
// clean and then failed the run with an unlocated frozen-plan error.
const duplicate = envRefs.find((ref, i) => envRefs.indexOf(ref) !== i);
if (duplicate !== undefined) {
ctx.err([...path, "env"], `${stepLabel} "env" contains a duplicate entry: "${duplicate}".`);
}
else {
unit.env = envRefs;
}
}
else {
ctx.err([...path, "env"], `${stepLabel} "env" must be a list of non-empty env asset refs.`);
}
}
const isolation = parseEnumField(ctx, raw.isolation, [...path, "isolation"], `${stepLabel} "isolation"`, PROGRAM_ISOLATION_KINDS);
if (isolation !== undefined)
unit.isolation = isolation;
return unit;
}
/**
* Parse `unit.exec` — the argv-array shell-command surface.
*
* Deliberately NO shell-string spelling: the child is spawned directly from
* this array, so shell metacharacters are inert literal bytes and the whole
* quoting/injection class is structurally absent. An author who wants a
* pipeline writes the interpreter explicitly (`["bash", "-lc", "…"]`), which
* keeps that decision visible in the frontmatter diff.
*/
function parseExec(ctx, raw, path, stepLabel) {
if (!isRecord(raw)) {
ctx.err(path, `${stepLabel} "exec" must be a mapping with a "command" argv list.`);
return undefined;
}
checkUnknownKeys(ctx, raw, path, EXEC_KEYS, `${stepLabel} "exec"`);
const command = parseExecCommand(ctx, raw.command, [...path, "command"], stepLabel);
if (command === undefined)
return undefined;
const exec = { command };
const cwd = parseExecCwd(ctx, raw.cwd, [...path, "cwd"], stepLabel);
if (cwd !== undefined)
exec.cwd = cwd;
const passEnv = parseExecPassEnv(ctx, raw.pass_env, [...path, "pass_env"], stepLabel);
if (passEnv !== undefined)
exec.passEnv = passEnv;
return exec;
}
/**
* `pass_env:` — extra parent-process env var NAMES the child may see on top of
* the default allowlist. NAMES ONLY: a value would be a plaintext secret in the
* frozen plan, which is exactly what `env:` bindings exist to avoid.
*/
function parseExecPassEnv(ctx, raw, path, stepLabel) {
if (raw === undefined)
return undefined;
if (!Array.isArray(raw) || raw.length === 0) {
ctx.err(path, `${stepLabel} "exec.pass_env" must be a non-empty list of environment variable NAMES to copy through from ` +
`akm's own environment, e.g. pass_env: [CARGO_HOME]. Values never appear here — use "env:" bindings for those.`);
return undefined;
}
const names = [];
for (const [index, entry] of raw.entries()) {
if (typeof entry !== "string" || !WORKFLOW_ENV_VAR_NAME_PATTERN.test(entry)) {
ctx.err(path, `${stepLabel} "exec.pass_env[${index}]" must be an environment variable name matching ` +
`${WORKFLOW_ENV_VAR_NAME_PATTERN.source}.`);
return undefined;
}
if (names.includes(entry)) {
ctx.err(path, `${stepLabel} "exec.pass_env" lists "${entry}" more than once.`);
return undefined;
}
names.push(entry);
}
return names;
}
/** The argv array itself: a non-empty list of non-empty strings. */
function parseExecCommand(ctx, raw, path, stepLabel) {
if (!Array.isArray(raw) || raw.length === 0) {
ctx.err(path, `${stepLabel} "exec" requires "command": a non-empty argv list, e.g. command: ["bun", "run", "test:unit"]. ` +
`A single shell string is not accepted — the command is spawned directly, never through a shell.`);
return undefined;
}
const argv = [];
for (const [index, entry] of raw.entries()) {
if (typeof entry !== "string" || entry === "") {
ctx.err(path, `${stepLabel} "exec.command[${index}]" must be a non-empty string.`);
return undefined;
}
if (entry.includes("\0")) {
ctx.err([...path, index], `${stepLabel} "exec.command[${index}]" may not contain NUL bytes.`);
return undefined;
}
argv.push(entry);
}
return argv;
}
/**
* The optional relative `cwd:`. Rejected here (statically) for absolute paths
* and `..` segments; containment against the resolved base directory is
* re-checked at dispatch, so a symlinked subdirectory cannot escape either.
*/
function parseExecCwd(ctx, raw, path, stepLabel) {
if (raw === undefined)
return undefined;
if (typeof raw !== "string" || raw.trim() === "") {
ctx.err(path, `${stepLabel} "exec.cwd" must be a non-empty relative path inside the unit's working directory.`);
return undefined;
}
const value = raw.trim();
if (!isContainedRelativePath(value)) {
ctx.err(path, `${stepLabel} "exec.cwd" (${JSON.stringify(value)}) must be a RELATIVE path inside the unit's working ` +
`directory — absolute paths, Windows drive letters, "~", and ".." segments are rejected.`);
return undefined;
}
if (ctx.validateExecCwd) {
const semantic = ctx.validateExecCwd(value);
if (!semantic.ok) {
ctx.errors.push({
code: semantic.code,
line: ctx.lineAt(path),
message: semantic.message,
});
return undefined;
}
return semantic.value;
}
return value;
}
function parseMap(ctx, raw, path, stepLabel) {
if (!isRecord(raw)) {
ctx.err(path, `${stepLabel} "map" must be a mapping with an "over" key.`);
return undefined;
}
checkUnknownKeys(ctx, raw, path, MAP_KEYS, `${stepLabel} "map"`);
let over = "";
if (typeof raw.over === "string" && raw.over.trim() !== "") {
over = raw.over.trim();
checkReferenceSyntax(ctx, over, [...path, "over"], `${stepLabel} "over"`);
}
else {
ctx.err([...path, "over"], `${stepLabel} "map" requires "over": a reference naming the item list (e.g. steps.discover.output.files).`);
}
let concurrency;
if (raw.concurrency !== undefined) {
if (typeof raw.concurrency === "number" && Number.isInteger(raw.concurrency) && raw.concurrency > 0) {
concurrency = Math.min(raw.concurrency, WORKFLOW_MAX_CONCURRENCY);
}
else {
ctx.err([...path, "concurrency"], `${stepLabel} "concurrency" must be a positive integer.`);
}
}
const reducer = parseEnumField(ctx, raw.reducer, [...path, "reducer"], `${stepLabel} "reducer"`, PROGRAM_REDUCERS);
const unit = raw.unit !== undefined ? parseUnit(ctx, raw.unit, [...path, "unit"], stepLabel) : undefined;
const map = { over };
if (concurrency !== undefined)
map.concurrency = concurrency;
if (reducer !== undefined)
map.reducer = reducer;
if (unit !== undefined)
map.unit = unit;
return map;
}
function parseRoute(ctx, raw, path, stepLabel, stepIndex, routeChecks) {
if (!isRecord(raw)) {
ctx.err(path, `${stepLabel} "route" must be a mapping with "input" and "when" keys.`);
return undefined;
}
checkUnknownKeys(ctx, raw, path, ROUTE_KEYS, `${stepLabel} "route"`);
let input = "";
if (typeof raw.input === "string" && raw.input.trim() !== "") {
input = raw.input.trim();
checkReferenceSyntax(ctx, input, [...path, "input"], `${stepLabel} "route.input"`);
}
else {
ctx.err([...path, "input"], `${stepLabel} "route" requires "input": a reference naming the value to route on.`);
}
const check = { stepIndex, stepLabel, branches: [] };
const whenPath = [...path, "when"];
if (!Array.isArray(raw.when) || raw.when.length === 0) {
ctx.err(whenPath, `${stepLabel} "route" requires "when": a non-empty list of { match, step } branches (e.g. when: [{ match: pass, step: ship }]).`);
}
else {
const seenMatches = new Map();
raw.when.forEach((branch, i) => {
const branchPath = [...whenPath, i];
if (!isRecord(branch)) {
ctx.err(branchPath, `${stepLabel} "when[${i}]" must be a mapping: { match, step }.`);
return;
}
checkUnknownKeys(ctx, branch, branchPath, ROUTE_BRANCH_KEYS, `${stepLabel} "when[${i}]"`);
const matchLine = ctx.lineAt([...branchPath, "match"]);
if (branch.match === undefined ||
(typeof branch.match !== "string" && typeof branch.match !== "number" && typeof branch.match !== "boolean")) {
ctx.err([...branchPath, "match"], `${stepLabel} "when[${i}].match" must be a string, number, or boolean.`);
return;
}
const match = String(branch.match);
// The frozen-plan decoder requires every `when` key to be non-empty, so an
// empty match parsed and linted clean and then failed the run with an
// unlocated "Invalid frozen workflow plan". Reject it here, at the line.
if (match === "") {
ctx.errAtLine(matchLine, `${stepLabel} "when[${i}].match" must not be empty.`);
return;
}
if (typeof branch.step !== "string" || branch.step.trim() === "") {
ctx.err([...branchPath, "step"], `${stepLabel} "when[${i}].step" must be a step id string.`);
return;
}
const stepId = branch.step.trim();
const stepLine = ctx.lineAt([...branchPath, "step"]);
const firstLine = seenMatches.get(match);
if (firstLine !== undefined) {
ctx.errAtLine(matchLine, `${stepLabel} has a duplicate "when" match "${match}" (first declared on line ${firstLine}). Matches must be unique.`);
return;
}
seenMatches.set(match, matchLine);
check.branches.push({ match, stepId, line: stepLine });
});
}
let defaultStepId;
if (raw.default !== undefined) {
if (typeof raw.default === "string" && raw.default.trim() !== "") {
defaultStepId = raw.default.trim();
check.defaultTarget = { stepId: defaultStepId, line: ctx.lineAt([...path, "default"]) };
}
else {
ctx.err([...path, "default"], `${stepLabel} "route.default" must be a step id string.`);
}
}
routeChecks.push(check);
const route = { input, branches: check.branches.map(({ match, stepId }) => ({ match, stepId })) };
if (defaultStepId !== undefined)
route.defaultStepId = defaultStepId;
return route;
}
function parseInputs(ctx, raw, path, stepLabel) {
if (raw === undefined)
return undefined;
if (!Array.isArray(raw) || raw.length === 0) {
ctx.err(path, `${stepLabel} "inputs" must be a non-empty list of reference strings.`);
return undefined;
}
const out = [];
// The frozen-plan decoder requires uniqueness (validateStringArray(..., true)),
// so duplicates parsed and linted clean and then failed the run with an
// unlocated "Invalid frozen workflow plan". Reject them here, on the entry.
const seen = new Set();
raw.forEach((entry, i) => {
if (typeof entry !== "string" || entry.trim() === "") {
ctx.err([...path, i], `${stepLabel} "inputs[${i}]" must be a non-empty reference string.`);
return;
}
const value = entry.trim();
if (seen.has(value)) {
ctx.err([...path, i], `${stepLabel} "inputs[${i}]" duplicates an earlier entry: "${value}".`);
return;
}
seen.add(value);
checkReferenceSyntax(ctx, value, [...path, i], `${stepLabel} "inputs[${i}]"`);
out.push(value);
});
return out.length > 0 ? out : undefined;
}
function parseGate(ctx, raw, path, stepLabel) {
if (!isRecord(raw)) {
ctx.err(path, `${stepLabel} "gate" must be a mapping with any of: ${GATE_KEYS.join(", ")}.`);
return undefined;
}
checkUnknownKeys(ctx, raw, path, GATE_KEYS, `${stepLabel} "gate"`);
const gate = {};
if (raw.max_loops !== undefined) {
if (typeof raw.max_loops === "number" && Number.isInteger(raw.max_loops) && raw.max_loops >= 1) {
gate.maxLoops = raw.max_loops;
}
else {
ctx.err([...path, "max_loops"], `${stepLabel} "gate.max_loops" must be an integer of at least 1.`);
}
}
return gate;
}
// ---------------------------------------------------------------------------
// Field helpers
// ---------------------------------------------------------------------------
/**
* Engine names must already satisfy the frozen-plan grammar
* (`WORKFLOW_ENGINE_NAME_PATTERN`, max 63 chars) at parse time — the decoder
* enforces the same bound on persisted plans, and a name that only fails there
* surfaces as an unlocated "Invalid frozen workflow plan" at `workflow run`.
*/
function parseEngineName(ctx, raw, path, label) {
if (typeof raw !== "string" || raw.trim() === "") {
ctx.err(path, `${label} must be a non-empty engine name.`);
return undefined;
}
const name = raw.trim();
if (!WORKFLOW_ENGINE_NAME_PATTERN.test(name) || name.length > WORKFLOW_MAX_ENGINE_NAME_LENGTH) {
ctx.err(path, `${label} has an invalid engine name ${JSON.stringify(name)}. Engine names are lowercase words of letters ` +
`and digits separated by single dashes, starting with a letter (e.g. "code-review-llm"), at most ` +
`${WORKFLOW_MAX_ENGINE_NAME_LENGTH} characters.`);
return undefined;
}
return name;
}
function parseRetry(ctx, raw, path, stepLabel) {
if (raw === undefined)
return undefined;
if (!isRecord(raw)) {
ctx.err(path, `${stepLabel} "retry" must be a mapping: { max: <n>, on: [<failure_reason>, …] }.`);
return undefined;
}
checkUnknownKeys(ctx, raw, path, RETRY_KEYS, `${stepLabel} "retry"`);
let ok = true;
if (!(typeof raw.max === "number" && Number.isInteger(raw.max) && raw.max >= 0)) {
ctx.err([...path, "max"], `${stepLabel} "retry.max" is required and must be a non-negative integer.`);
ok = false;
}
const on = [];
if (Array.isArray(raw.on) && raw.on.length > 0) {
raw.on.forEach((reason, i) => {
if (typeof reason === "string" && PROGRAM_RETRY_REASONS.includes(reason)) {
on.push(reason);
}
else {
ctx.err([...path, "on", i], `${stepLabel} "retry.on" has unknown failure reason ${JSON.stringify(reason)}. Valid reasons: ${PROGRAM_RETRY_REASONS.join(", ")}.`);
ok = false;
}
});
}
else {
ctx.err([...path, "on"], `${stepLabel} "retry.on" is required and must be a non-empty list of failure reasons (${PROGRAM_RETRY_REASONS.join(", ")}).`);
ok = false;
}
return ok ? { max: raw.max, on } : undefined;
}
function parseTimeoutField(ctx, raw, path, label) {
if (raw === undefined)
return undefined;
if (typeof raw === "number") {
if (Number.isInteger(raw) && raw > 0)
return checkTimeoutCeiling(ctx, raw, path, label, String(raw));
ctx.err(path, `${label} has a non-positive timeout ${JSON.stringify(raw)}. ${TIMEOUT_HINT}.`);
return undefined;
}
if (typeof raw !== "string") {
ctx.err(path, `${label} must be a duration string. ${TIMEOUT_HINT}.`);
return undefined;
}
const value = raw.trim().toLowerCase();
if (value === "none")
return null;
const match = value.match(TIMEOUT_VALUE);
if (!match) {
ctx.err(path, `${label} has an invalid timeout "${raw}". ${TIMEOUT_HINT}.`);
return undefined;
}
const n = Number.parseInt(match[1], 10);
const unit = match[2] ?? "ms";
const timeoutMs = unit === "m" ? n * 60_000 : unit === "s" ? n * 1_000 : n;
if (timeoutMs <= 0) {
ctx.err(path, `${label} has a non-positive timeout "${raw}". Use a positive duration or "none".`);
return undefined;
}
return checkTimeoutCeiling(ctx, timeoutMs, path, label, raw);
}
/**
* Timeouts freeze into `IrInvocation.timeoutMs`, whose decoder bound is
* `WORKFLOW_MAX_TIMEOUT_MS` (setTimeout's 32-bit signed ceiling). Enforce the
* same ceiling here so an oversized duration fails with a line anchor instead
* of an unlocated decode error at `workflow run`.
*/
function checkTimeoutCeiling(ctx, timeoutMs, path, label, raw) {
if (timeoutMs <= WORKFLOW_MAX_TIMEOUT_MS)
return timeoutMs;
ctx.err(path, `${label} has a timeout "${raw}" above the maximum of ${WORKFLOW_MAX_TIMEOUT_MS} ms (about 24.8 days). ` +
`Use a shorter duration or "none" for no timeout.`);
return undefined;
}
function parseEnumField(ctx, raw, path, label, allowed) {
if (raw === undefined)
return undefined;
if (typeof raw === "string" && allowed.includes(raw))
return raw;
ctx.err(path, `${label} must be one of: ${allowed.join(" | ")} (got ${JSON.stringify(raw)}).`);
return undefined;
}
/** Parse only invocation tuning. Connection identity belongs to a named engine. */
function parseLlmOverrides(ctx, raw, path, label) {
if (raw === undefined)
return undefined;
if (!isRecord(raw)) {
ctx.err(path, `${label} must be a mapping of LLM invocation overrides.`);
return undefined;
}
const keys = [
"temperature",
"max_tokens",
"supports_json_schema",
"extra_params",
"context_length",
"enable_thinking",
"reasoning_effort",
];
checkUnknownKeys(ctx, raw, path, keys, label);
const result = {};
if (raw.temperature !== undefined) {
if (typeof raw.temperature === "number" && Number.isFinite(raw.temperature))
result.temperature = raw.temperature;
else
ctx.err([...path, "temperature"], `${label}.temperature must be a finite number.`);
}
if (raw.max_tokens !== undefined) {
if (typeof raw.max_tokens === "number" && Number.isInteger(raw.max_tokens) && raw.max_tokens > 0) {
result.maxTokens = raw.max_tokens;
}
else
ctx.err([...path, "max_tokens"], `${label}.max_tokens must be a positive integer.`);
}
if (raw.supports_json_schema !== undefined) {
if (typeof raw.supports_json_schema === "boolean")
result.supportsJsonSchema = raw.supports_json_schema;
else
ctx.err([...path, "supports_json_schema"], `${label}.supports_json_schema must be a boolean.`);
}
if (raw.extra_params !== undefined) {
if (!isRecord(raw.extra_params)) {
ctx.err([...path, "extra_params"], `${label}.extra_params must be a JSON object.`);
}
else {
const issues = validateExtraParams(raw.extra_params);
for (const issue of issues) {
ctx.err([...path, "extra_params", ...issue.path], `${formatExtraParamsIssue(`${label}.extra_params`, issue)}.`);
}
if (jsonBytes(raw.extra_params) > WORKFLOW_MAX_EXTRA_PARAMS_BYTES) {
ctx.err([...path, "extra_params"], `${label}.extra_params exceeds the 64 KiB resource limit.`);
}
if (issues.length === 0 && jsonBytes(raw.extra_params) <= WORKFLOW_MAX_EXTRA_PARAMS_BYTES) {
result.extraParams = raw.extra_params;
}
}
}
if (raw.context_length !== undefined) {
if (typeof raw.context_length === "number" && Number.isInteger(raw.context_length) && raw.context_length > 0) {
result.contextLength = raw.context_length;
}
else
ctx.err([...path, "context_length"], `${label}.context_length must be a positive integer.`);
}
if (raw.enable_thinking !== undefined) {
if (typeof raw.enable_thinking === "boolean")
result.enableThinking = raw.enable_thinking;
else
ctx.err([...path, "enable_thinking"], `${label}.enable_thinking must be a boolean.`);
}
if (raw.reasoning_effort !== undefined) {
if (typeof raw.reasoning_effort === "string" && raw.reasoning_effort.trim().length > 0) {
result.reasoningEffort = raw.reasoning_effort;
}
else
ctx.err([...path, "reasoning_effort"], `${label}.reasoning_effort must be a non-empty string.`);
}
return Object.keys(result).length > 0 ? result : undefined;
}
function parseSchemaObject(ctx, raw, path, label) {
if (raw === undefined)
return undefined;
if (!isRecord(raw)) {
ctx.err(path, `${label} must be a JSON Schema object (e.g. { type: object, properties: { … } }).`);
return undefined;
}
if (jsonBytes(raw) > WORKFLOW_MAX_SCHEMA_BYTES) {
ctx.err(path, `${label} exceeds the 256 KiB resource limit.`);
}
checkSchemaDefinition(ctx, raw, path, label);
return raw;
}
/**
* Validate an author-declared schema AS a schema (`output:` and `params`
* declarations). The runtime enforces only a JSON Schema subset
* (`core/json-schema.ts`); a typo'd `type` or a keyword the subset ignores
* would silently constrain nothing at run time — a gate depending on a no-op
* schema is worse than a loud failure here, so both are parse ERRORS.
*/
function checkSchemaDefinition(ctx, schema, path, label) {
for (const issue of checkJsonSchemaDefinition(schema)) {
const issuePath = [...path, ...issue.path];
if (issue.kind === "unsupported") {
ctx.err(issuePath, `${label} (at ${issue.pointer}): ${issue.message}. Supported JSON Schema keywords: ` +
`${JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS}.`);
}
else {
ctx.err(issuePath, `${label} is not a valid JSON Schema (at ${issue.pointer}): ${issue.message}.`);
}
}
}
function checkReferenceSyntax(ctx, text, path, label) {
const result = parseReference(text);
if (!result.ok)
ctx.err(path, `${label}: ${result.message}`);
}
function checkUnknownKeys(ctx, obj, path, allowed, label) {
for (const key of Object.keys(obj)) {
if (!allowed.includes(key)) {
ctx.err([...path, key], `Unknown ${label} key "${key}". Allowed keys: ${allowed.join(", ")}.`);
}
}
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function describeError(cause) {
return cause instanceof Error ? cause.message : String(cause);
}
/** Strip the yaml package's multi-line code frame down to the first line. */
function yamlErrorMessage(message) {
const first = message.split("\n", 1)[0] ?? message;
return first.replace(/ at line \d+, column \d+:?\s*$/, "").trim();
}
function sortErrors(errors) {
return [...errors].sort((a, b) => a.line - b.line);
}