UNPKG

@agentled/cli

Version:

CLI for Agentled — manage workflows, apps, and knowledge from the command line. Zero context-window cost for AI agents.

293 lines 15.2 kB
/** * Client-side preflight checks for a pipeline JSON before it's sent to the * server. Catches the silent-strip failure mode (invalid step types, missing * required fields, unreachable steps, silently-stripped root fields) without * spending a create + validate + delete round trip. * * The valid step types list is kept in sync with the orchestrator's runtime * VALID_STEP_TYPES via the drift test in * agentled-mcp-server/__tests__/skill-step-types.test.ts. */ import { VALID_CONTEXT_FIELD_TYPE_VALUES, CONTEXT_FIELD_TYPE_ALIASES, suggestContextFieldTypeFix, } from '../context-schema.js'; import { VALID_BUILTIN_TOOL_VALUES, suggestBuiltinToolFix, } from '../builtin-tools-catalog.js'; /** * Canonical valid step types. Must stay in sync with: * shared/services/chat/orchestrator/validators/index.ts (runtime) * packages/cli/skills/agentled/SKILL.md (agent-facing doc) * agentled-mcp-server/__tests__/skill-step-types.test.ts (drift guard) */ const VALID_STEP_TYPES = new Set([ 'trigger', 'appAction', 'aiAction', 'aiActionWithTools', 'toolAction', 'code', 'knowledgeSync', 'return', 'milestone', 'share', 'wait', 'branch', 'parallel', 'loop', 'end_if', 'agentOrchestrator', 'manualAction', 'systemAction', ]); /** * Root-level fields that the API silently strips. Detecting these at the * step root is the single highest-leverage check — it's the exact class of * bug that produced the 201-created-but-broken workflows. */ const STRIPPED_ROOT_FIELDS = { prompt: 'Move to `pipelineStepPrompt.template`', responseStructure: 'Move to `pipelineStepPrompt.responseStructure`', appId: 'Move into `app: { id, actionId, source: "native" }`', actionId: 'Move into `app: { id, actionId, source: "native" }`', listKey: 'Use `knowledgeSync.listKey` (for knowledgeSync steps) or put inside `stepInputData`', channel: 'Put inside `stepInputData.channel`', webhookUrl: 'Put inside `stepInputData.webhookUrl`', condition: 'Use `entryConditions.criteria[]` with { variable, operator, value }', triggerType: 'Use `pipelineStepStartConditions.trigger.type` instead — `triggerType` at step root is silently stripped', }; /** Steps where we expect an outgoing `next` link (everything except terminals). */ const TERMINAL_TYPES = new Set(['milestone', 'return', 'end_if']); export function preflightPipeline(pipeline) { const errors = []; const warnings = []; if (!pipeline || typeof pipeline !== 'object') { errors.push({ severity: 'error', code: 'not-object', message: 'Pipeline is not a JSON object' }); return { valid: false, errors, warnings, stepCount: 0 }; } if (typeof pipeline.name !== 'string' || pipeline.name.trim() === '') { errors.push({ severity: 'error', code: 'missing-name', message: 'Top-level `name` is required' }); } const steps = pipeline.steps; if (!Array.isArray(steps) || steps.length === 0) { errors.push({ severity: 'error', code: 'missing-steps', message: 'Top-level `steps` must be a non-empty array' }); return { valid: errors.length === 0, errors, warnings, stepCount: 0 }; } const stepIds = new Set(); for (const step of steps) { if (!step || typeof step !== 'object') { errors.push({ severity: 'error', code: 'bad-step', message: 'Step is not an object' }); continue; } if (typeof step.id !== 'string' || step.id.trim() === '') { errors.push({ severity: 'error', code: 'missing-id', message: 'Step is missing `id`' }); } else if (stepIds.has(step.id)) { errors.push({ severity: 'error', stepId: step.id, code: 'duplicate-id', message: `Duplicate step id: ${step.id}` }); } else { stepIds.add(step.id); } // type check — the silent-strip bug if (typeof step.type !== 'string') { errors.push({ severity: 'error', stepId: step.id, code: 'missing-type', message: 'Step is missing `type`' }); } else if (!VALID_STEP_TYPES.has(step.type)) { errors.push({ severity: 'error', stepId: step.id, code: 'invalid-type', message: `Unknown step type "${step.type}". Valid: ${[...VALID_STEP_TYPES].sort().join(', ')}`, suggestedFix: suggestTypeFix(step.type), }); } // silently-stripped root fields for (const [field, fix] of Object.entries(STRIPPED_ROOT_FIELDS)) { if (Object.prototype.hasOwnProperty.call(step, field)) { warnings.push({ severity: 'warning', stepId: step.id, code: 'stripped-root-field', message: `Field \`${field}\` at step root will be silently stripped on save`, suggestedFix: fix, }); } } // aiAction + aiActionWithTools must have pipelineStepPrompt.template if ((step.type === 'aiAction' || step.type === 'aiActionWithTools')) { const tpl = step.pipelineStepPrompt?.template; if (typeof tpl !== 'string' || tpl.trim() === '') { errors.push({ severity: 'error', stepId: step.id, code: 'missing-prompt-template', message: `${step.type} step requires \`pipelineStepPrompt.template\``, }); } } // aiActionWithTools — each tool's builtinType must be a known value. // Typos are silently ignored at runtime, so the AI step runs but the // tool is never registered (same silent-failure class as step types). if (step.type === 'aiActionWithTools') { const tools = Array.isArray(step.tools) ? step.tools : []; for (let i = 0; i < tools.length; i += 1) { const tool = tools[i]; if (!tool || typeof tool !== 'object') continue; const bt = tool.builtinType; if (typeof bt !== 'string') continue; // non-builtin (app action, webhook) — not our concern if (!VALID_BUILTIN_TOOL_VALUES.has(bt)) { errors.push({ severity: 'error', stepId: step.id, code: 'invalid-builtin-type', message: `tools[${i}].builtinType "${bt}" is not a known builtin. Run \`agentled tools builtins\` to list valid values.`, suggestedFix: suggestBuiltinToolFix(bt), }); } } } // appAction must have app.id + app.actionId if (step.type === 'appAction') { if (!step.app || typeof step.app !== 'object') { errors.push({ severity: 'error', stepId: step.id, code: 'missing-app', message: 'appAction step requires `app: { id, actionId, source }`', }); } else { if (typeof step.app.id !== 'string') { errors.push({ severity: 'error', stepId: step.id, code: 'missing-app-id', message: '`app.id` is required on appAction steps' }); } if (typeof step.app.actionId !== 'string') { errors.push({ severity: 'error', stepId: step.id, code: 'missing-action-id', message: '`app.actionId` is required on appAction steps' }); } } } // trigger steps need pipelineStepStartConditions.trigger.type // (the runtime reads this; `triggerType` at step root is not in the // step schema and is silently stripped on save) if (step.type === 'trigger') { const tType = step.pipelineStepStartConditions?.trigger?.type; if (typeof tType !== 'string') { errors.push({ severity: 'error', stepId: step.id, code: 'missing-trigger-type', message: 'trigger step requires `pipelineStepStartConditions.trigger.type` (manual | schedule | webhook | event | delay | app_event)', suggestedFix: 'Set `pipelineStepStartConditions: { trigger: { type: "manual" } }` (or replace "manual" with schedule/webhook/event/delay/app_event).', }); } } // non-terminal steps should have a next.stepId if (!TERMINAL_TYPES.has(step.type)) { const nextId = step.next?.stepId; if (typeof step.type === 'string' && VALID_STEP_TYPES.has(step.type) && !nextId) { warnings.push({ severity: 'warning', stepId: step.id, code: 'missing-next', message: `Non-terminal step "${step.id}" has no \`next.stepId\` — will halt after this step`, }); } } } // next.stepId must point to an existing step for (const step of steps) { if (!step?.next?.stepId) continue; if (!stepIds.has(step.next.stepId)) { errors.push({ severity: 'error', stepId: step.id, code: 'dangling-next', message: `Step "${step.id}" next.stepId "${step.next.stepId}" does not reference any step`, }); } } // Context input-page field types — the other half of the silent-strip // failure mode. An invalid `type` on an input-page field falls // back to a plain text input at render time and type-aware pickers // (connected emails, country, multiselect) silently disappear. checkContextInputFields(pipeline, errors, warnings); return { valid: errors.length === 0, errors, warnings, stepCount: steps.length }; } function checkContextInputFields(pipeline, errors, warnings) { const ctx = pipeline?.context; if (!ctx || typeof ctx !== 'object') return; const sources = []; if (ctx.executionInputConfig?.fields) { sources.push({ scope: 'context.executionInputConfig', fields: ctx.executionInputConfig.fields }); } if (Array.isArray(ctx.inputPages)) { for (const page of ctx.inputPages) { const fields = page?.configuration?.fields; if (fields) sources.push({ scope: `context.inputPages["${page.pathname ?? page.title ?? '?'}"]`, fields }); } } for (const { scope, fields } of sources) { if (!Array.isArray(fields)) continue; checkFieldArray(fields, scope, errors, warnings); } } function checkFieldArray(fields, scope, errors, warnings) { for (let i = 0; i < fields.length; i += 1) { const f = fields[i]; if (!f || typeof f !== 'object') continue; const path = `${scope}.fields[${i}]${typeof f.name === 'string' ? ` "${f.name}"` : ''}`; // Missing required meta if (typeof f.name !== 'string' || f.name.trim() === '') { errors.push({ severity: 'error', code: 'input-field-missing-name', message: `${path} is missing \`name\``, }); } if (typeof f.type !== 'string' || f.type.trim() === '') { errors.push({ severity: 'error', code: 'input-field-missing-type', message: `${path} is missing \`type\`. Run \`agentled schema --context\` to see valid values.`, }); continue; } // Legacy alias — warn with the canonical name if (Object.prototype.hasOwnProperty.call(CONTEXT_FIELD_TYPE_ALIASES, f.type)) { warnings.push({ severity: 'warning', code: 'input-field-legacy-alias', message: `${path} uses legacy alias type "${f.type}"`, suggestedFix: `Rename to \`${CONTEXT_FIELD_TYPE_ALIASES[f.type]}\` (canonical name).`, }); } else if (!VALID_CONTEXT_FIELD_TYPE_VALUES.has(f.type)) { errors.push({ severity: 'error', code: 'invalid-input-field-type', message: `${path} has unknown \`type\` "${f.type}". Run \`agentled schema --context\` to list valid values.`, suggestedFix: suggestContextFieldTypeFix(f.type) ?? 'Pick one of: text, textarea, select, multiselect, boolean, numeric, email, url, date, datetime, list, connected_emails_selector_multiple, …', }); } // Type-specific extra-key checks if (f.type === 'select' || f.type === 'multiselect') { if (!Array.isArray(f.options) || f.options.length === 0) { warnings.push({ severity: 'warning', code: 'input-field-missing-options', message: `${path} is a \`${f.type}\` but has no \`options\` array`, suggestedFix: 'Add `options: [{ label, value }, …]` (or set `allowCustomValues: true` if values are user-entered).', }); } } if (f.type === 'list') { if (!Array.isArray(f.itemFields) || f.itemFields.length === 0) { warnings.push({ severity: 'warning', code: 'input-field-missing-item-fields', message: `${path} is a \`list\` but has no \`itemFields\``, suggestedFix: 'Add `itemFields: [{ name, label, type }, …]` defining the shape of each row.', }); } else { checkFieldArray(f.itemFields, `${path}.itemFields`, errors, warnings); } } } } /** * Heuristic fix suggestions for the exact invented types agents commonly produce. */ function suggestTypeFix(invalid) { const map = { ai: 'Use `type: "aiAction"` (or `aiActionWithTools` if the LLM needs runtime tools).', integration: 'Use `type: "appAction"` with `app: { id, actionId, source: "native" }`.', conditional_integration: 'Use `type: "appAction"` with `entryConditions.criteria`.', knowledge_graph_query: 'Use `type: "appAction"` with `app.id: "kg"`, actionId one of `kg.read-list`/`kg.read-text`/`kg.get-rows-by-ids`/`kg.traverse-edges`.', knowledge_graph_upsert: 'Use `type: "appAction"` with `app.id: "kg"`, actionId `kg.add-rows`/`kg.update-rows`/`kg.upsert-text`.', knowledge_graph: 'Use `type: "appAction"` with `app.id: "kg"` (see `agentled apps actions kg`).', slack: 'Use `type: "appAction"` with `app.id: "slack"` (or a `webhook` appAction if using a Slack incoming webhook URL).', gmail: 'Use `type: "appAction"` with `app.id: "gmail"`.', webhook: '`webhook` is a `triggerType` on a `trigger` step, not a step type. For outbound webhooks use `app.id: "webhook"` with actionId `webhook.trigger`.', schedule: '`schedule` is a `triggerType` on a `trigger` step, not a step type.', }; return map[invalid]; } export function formatPreflightIssue(issue) { const scope = issue.stepId ? `[${issue.stepId}] ` : ''; const fix = issue.suggestedFix ? `\n fix: ${issue.suggestedFix}` : ''; return ` - ${scope}${issue.message} (${issue.code})${fix}`; } //# sourceMappingURL=preflight.js.map