@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
JavaScript
/**
* 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