/** * Node output reference resolution for the Ion workflow engine. * * Resolves `$nodeId.field` references in workflow conditions and prompts, * with strict schema-aware validation and descriptive errors. */ // --------------------------------------------------------------------------- // Output reference result // --------------------------------------------------------------------------- export type OutputRefKind = 'value' | 'empty'; export interface OutputRefResult { /** Whether the field had a value or was empty. */ kind: OutputRefKind; /** The resolved value (empty string for missing optional fields). */ value: string; } // --------------------------------------------------------------------------- // OutputRefError // --------------------------------------------------------------------------- export class OutputRefError extends Error { public readonly nodeId: string; public readonly field: string; constructor(nodeId: string, field: string, message: string) { super(`Output reference error for node "${nodeId}".${field}: ${message}`); this.name = 'OutputRefError'; this.nodeId = nodeId; this.field = field; } } // --------------------------------------------------------------------------- // Schema helpers // --------------------------------------------------------------------------- /** * Extract declared field names from an output_format schema. * * The output_format can be: * - A JSON Schema object with `properties` (standard) * - A string describing the format (treated as having no declared fields) * - Undefined (no schema) * * Returns a Set of field names that are declared in the schema. */ export function declaredFieldsFromSchema( outputFormat: Record | string | undefined, ): Set { if (!outputFormat || typeof outputFormat === 'string') { return new Set(); } const properties = outputFormat['properties']; if (properties && typeof properties === 'object' && properties !== null) { return new Set(Object.keys(properties as Record)); } return new Set(); } // --------------------------------------------------------------------------- // Node output resolution // --------------------------------------------------------------------------- /** * Resolve a specific field from a node's output. * * Behavior: * - If the field is present in the output, returns `{ kind: 'value', value }`. * - If the field is declared in the schema but not present in the output, * returns `{ kind: 'empty', value: '' }` (optional field not set). * - If the field is NOT declared in the schema AND not in the output, * throws `OutputRefError` (undeclared reference). * - If the field is NOT declared in the schema but IS in the output, * returns `{ kind: 'value', value }` (dynamic output). * * The `declaredFields` parameter should come from `declaredFieldsFromSchema()`. */ export function resolveNodeOutputField( nodeOutput: Record, nodeId: string, field: string, declaredFields?: Set, ): OutputRefResult { // Check if the field exists in the output. if (field in nodeOutput) { const rawValue = nodeOutput[field]; // Convert the value to a string. if (rawValue === null || rawValue === undefined) { // Field key exists but value is nullish — treat as empty. return { kind: 'empty', value: '' }; } if (typeof rawValue === 'string') { return { kind: 'value', value: rawValue }; } // Non-string values are JSON-serialized. return { kind: 'value', value: JSON.stringify(rawValue) }; } // Field is not in the output. Check if it was declared in the schema. const isDeclared = declaredFields?.has(field) ?? false; if (isDeclared) { // Declared but not present — optional field not set. return { kind: 'empty', value: '' }; } // Not declared and not present — this is an error. throw new OutputRefError( nodeId, field, `Field "${field}" is not declared in the output schema and is not present in the node output. Available fields: ${Object.keys(nodeOutput).join(', ') || '(none)'}`, ); }