Setting the file. One moment.
SDK · N8n:testmax N8n AI · n8n-io/n8n · Skills Docs
ContentsBack to the top of the page 167
function readPath
— line 167
This file
Number 40.20
Position 20 of 24
Type JavaScript
Size 26 KB
Lines 600 scripts/ sdk.mjs
JavaScript · 600 lines · 26 KB
10
11 const here = dirname ( fileURLToPath ( import . meta .url));
12 export const repo = resolve (here, '../../../..' );
13 const require = createRequire ( import . meta .url);
14 const SDK_DIST = join (repo, 'packages/@n8n/workflow-sdk/dist/index.js' );
15 // The same directories that the n8n node catalog gives to setSchemaBaseDirs().
16 const NODE_DEFINITION_DIRS = [
17 join (repo, 'packages/nodes-base/dist/node-definitions/' ),
18 join (repo, 'packages/@n8n/nodes-langchain/dist/node-definitions/' ),
19 ];
20
21 let loaded;
22 /** The workflow SDK with the node parameter schemas set, or null when the package is not built. */
23 export function loadSdk () {
24 if (loaded !== undefined ) return loaded;
25 if ( ! existsSync ( SDK_DIST )) return (loaded = null );
26 const sdk = require ( SDK_DIST );
27 const schemaDirs = NODE_DEFINITION_DIRS . filter (( dir ) => existsSync (dir));
28 // Without schema directories validateNodeConfig reports every node as valid.
29 sdk. setSchemaBaseDirs (schemaDirs);
30 return (loaded = { sdk, schemaDirs, missingDirs: NODE_DEFINITION_DIRS . filter (( d ) => ! existsSync (d)) });
31 }
32
33 // --- Node kinds ----------------------------------------------------------------
34
35 const BASE = 'n8n-nodes-base.' ;
36 const LC = '@n8n/n8n-nodes-langchain.' ;
37
38 /** Nodes that run for real in a scenario. They need no credentials and call no service. */
39 export const LOGIC_TYPES = new Set (
40 [
41 'set' , 'if' , 'switch' , 'filter' , 'merge' , 'aggregate' , 'splitOut' , 'code' , 'function' , 'functionItem' ,
42 'noOp' , 'splitInBatches' , 'itemLists' , 'sort' , 'limit' , 'removeDuplicates' , 'summarize' , 'dateTime' ,
43 'renameKeys' , 'compareDatasets' , 'markdown' , 'html' , 'xml' , 'crypto' , 'executionData' , 'stopAndError' ,
44 'respondToWebhook' , 'convertToFile' , 'extractFromFile' , 'debugHelper' ,
45 ]. map (( t ) => BASE + t),
46 );
47
48 /** Nodes that end up with one item, so `.first()` on them reads all of their output. */
49 export const SINGLE_ITEM_TYPES = new Set ([ 'aggregate' , 'summarize' ]. map (( t ) => BASE + t));
50
51 const TRIGGER_TYPES = new Set ([ 'webhook' , 'cron' , 'interval' , 'emailReadImap' , 'start' ]. map (( t ) => BASE + t));
52
53 export const isSticky = ( type ) => type === `${ BASE }stickyNote` ;
54 export const isTrigger = ( type ) => TRIGGER_TYPES . has (type) || type. toLowerCase (). includes ( 'trigger' );
55 export const isManualTrigger = ( type ) => type === `${ BASE }manualTrigger` ;
56 export const shortType = ( type ) => type. slice (type. lastIndexOf ( '.' ) + 1 );
57
58 /** True when type matches a full type name, or its part after the last dot (case-insensitive). */
59 export function typeMatches ( type , pattern ) {
60 return type === pattern || shortType (type). toLowerCase () === pattern. toLowerCase ();
61 }
62
63 const AI_ROOT_PLACEHOLDER = {
64 [ `${ LC }agent` ]: ( v ) => ({ output: v }),
65 [ `${ LC }openAiAssistant` ]: ( v ) => ({ output: v }),
66 [ `${ LC }informationExtractor` ]: ( v ) => ({ output: v }),
67 [ `${ LC }chainLlm` ]: ( v ) => ({ text: v }),
68 [ `${ LC }chainRetrievalQa` ]: ( v ) => ({ response: v }),
69 [ `${ LC }chainSummarization` ]: ( v ) => ({ output: { output_text: v } }),
70 [ `${ LC }sentimentAnalysis` ]: ( v ) => ({ sentimentAnalysis: { category: v } }),
71 [ `${ LC }googleGemini` ]: ( v ) => ({ content: { parts: [{ text: v }], role: 'model' }, finishReason: 'STOP' , index: 0 }),
72 };
73
74 /** Vendor nodes that call a model themselves, without a model sub-node. */
75 const AI_VENDOR_TYPES = new Set ([ 'googleGemini' , 'openAi' , 'anthropic' ]. map (( t ) => LC + t));
76
77 // --- Graph ---------------------------------------------------------------------
78
79 /** Nodes, edges and lookups of a workflow. Sticky notes and disabled nodes are left out. */
80 export function graph ( workflow ) {
81 const nodes = (workflow.nodes ?? []). filter (( n ) => ! isSticky (n.type) && ! n.disabled);
82 const byName = new Map (nodes. map (( n ) => [n.name, n]));
83 const edges = [];
84 for ( const [ from , outputs ] of Object. entries (workflow.connections ?? {})) {
85 for ( const [ type , branches ] of Object. entries (outputs ?? {})) {
86 (branches ?? []). forEach (( targets , output ) =>
87 (targets ?? []). forEach (( t ) => {
88 if (byName. has (from) && byName. has (t.node))
89 edges. push ({ from, to: t.node, type, output, input: t.index ?? 0 });
90 }),
91 );
92 }
93 }
94 const subnodes = new Set (edges. filter (( e ) => e.type. startsWith ( 'ai_' )). map (( e ) => e.from));
95 const mainParents = ( name ) => [ ...new Set (edges. filter (( e ) => e.type === 'main' && e.to === name). map (( e ) => e.from))];
96 const mainChildren = ( name ) => edges. filter (( e ) => e.type === 'main' && e.from === name);
97 const aiInputs = ( name , type ) => edges. filter (( e ) => e.to === name && e.type === type). map (( e ) => byName. get (e.from));
98 const triggers = nodes. filter (( n ) => isTrigger (n.type) && ! subnodes. has (n.name));
99 return { nodes, byName, edges, subnodes, mainParents, mainChildren, aiInputs, triggers };
100 }
101
102 /** Names of the nodes that a trigger reaches. AI sub-nodes count when they serve a reached node. */
103 export function reachable ( g ) {
104 const seen = new Set (g.triggers. map (( t ) => t.name));
105 const queue = [ ... seen];
106 while (queue. length ) {
107 const name = queue. shift ();
108 for ( const e of g.edges) {
109 if (e.type === 'main' && e.from === name && ! seen. has (e.to)) {
110 seen. add (e.to);
111 queue. push (e.to);
112 }
113 }
114 }
115 // A sub-node can serve another sub-node, for example a model inside an auto-fixing parser.
116 let grew = true ;
117 while (grew) {
118 grew = false ;
119 for ( const e of g.edges) {
120 if (e.type. startsWith ( 'ai_' ) && seen. has (e.to) && ! seen. has (e.from)) {
121 seen. add (e.from);
122 grew = true ;
123 }
124 }
125 }
126 return seen;
127 }
128
129 /** Names of the nodes downstream of name, through main connections. */
130 export function downstream ( g , name ) {
131 const seen = new Set ();
132 const queue = [name];
133 while (queue. length ) {
134 for ( const e of g. mainChildren (queue. shift ())) {
135 if ( ! seen. has (e.to)) {
136 seen. add (e.to);
137 queue. push (e.to);
138 }
139 }
140 }
141 return seen;
142 }
143
144 // --- Parameters and expressions --------------------------------------------------
145
146 /** Every string in a parameter tree, with its dotted path. */
147 export function strings ( value , path = '' ) {
148 if ( typeof value === 'string' ) return [{ path, value }];
149 if (Array. isArray (value)) return value. flatMap (( v , i ) => strings (v, `${ path }[${ i }]` ));
150 if (value && typeof value === 'object' )
151 return Object. entries (value). flatMap (([ k , v ]) => strings (v, path ? `${ path }.${ k }` : k));
152 return [];
153 }
154
155 const CODE_PARAMS = new Set ([ 'jsCode' , 'pythonCode' , 'functionCode' , 'code' ]);
156
157 /** The expression text of a parameter value, or null. Code fields count, because they use the same references. */
158 export function expressionText ( path , value ) {
159 if (value. startsWith ( '=' )) return value. slice ( 1 );
160 const key = path. split ( '.' ). pop (). replace ( / \[ \d + \] $ / , '' );
161 return CODE_PARAMS . has (key) ? value : null ;
162 }
163
164 const IDENT = / ^ [A-Za-z_$][\w$] * / ;
165
166 /** Read a property path such as `.a?.b['c'][0]` from text at index i. Returns { path, end, call }. */
167 function readPath ( text , i ) {
168 const path = [];
169 let dynamic = false ;
170 for (;;) {
171 if (text. startsWith ( '?.' , i)) i += 2 ;
172 else if (text[i] === '.' ) i += 1 ;
173 else if (text[i] !== '[' ) break ;
174 if (text[i] === '[' ) {
175 const close = text. indexOf ( ']' , i);
176 if (close < 0 ) break ;
177 const inner = text. slice (i + 1 , close). trim ();
178 if ( / ^ \d +$ / . test (inner)) path. push ( Number (inner));
179 else if ( / ^ ( ['"`] )( . * ) \1$ / . test (inner)) path. push (inner. slice ( 1 , - 1 ));
180 else {
181 dynamic = true ;
182 i = close + 1 ;
183 break ;
184 }
185 i = close + 1 ;
186 continue ;
187 }
188 const match = text. slice (i). match ( IDENT );
189 if ( ! match) break ;
190 path. push (match[ 0 ]);
191 i += match[ 0 ]. length ;
192 }
193 const call = / ^ \s * \( / . test (text. slice (i));
194 // A trailing method call, such as `.toUpperCase()`, is not a field.
195 if (call && path. length ) path. pop ();
196 return { path, end: i, call, dynamic };
197 }
198
199 const ACCESSORS = [
200 { re: / ^ \. item \b / , kind: 'item' },
201 { re: / ^ \. first \( \s * \) / , kind: 'first' },
202 { re: / ^ \. last \( \s * \) / , kind: 'last' },
203 { re: / ^ \. all \( \s * \)\[ \s * \d + \s * \] / , kind: 'all-index' },
204 { re: / ^ \. all \( \s * \) / , kind: 'all' },
205 { re: / ^ \. itemMatching \( [ ^ )] * \) / , kind: 'item' },
206 { re: / ^ \. pairedItem \( [ ^ )] * \) / , kind: 'item' },
207 ];
208
209 /**
210 * References in expression text: { node, kind, accessor, path, raw }.
211 * node is null for `$json` and `$input`, which read the main input of the node.
212 * path is null when the reference does not read item JSON, for example `$('A').params`.
213 */
214 export function references ( text ) {
215 const refs = [];
216 const named = / \$\( \s * ( ['"`] )((?: \\ . | (?! \1 ) . ) + ) \1 \s * \) | \$ node \[ \s * ( ['"`] )((?: \\ . | (?! \3 ) . ) + ) \3 \s * \] | \$ items \( \s * ( ['"`] )((?: \\ . | (?! \5 ) . ) + ) \5 / g ;
217 for ( const m of text. matchAll (named)) {
218 const node = (m[ 2 ] ?? m[ 4 ] ?? m[ 6 ]). replace ( / \\ ( . )/ g , '$1' );
219 let i = m.index + m[ 0 ]. length ;
220 let accessor = m[ 4 ] !== undefined ? 'node' : m[ 6 ] !== undefined ? 'items' : null ;
221 if (accessor === null ) {
222 const rest = text. slice (i);
223 const hit = ACCESSORS . find (( a ) => a.re. test (rest));
224 if (hit) {
225 accessor = hit.kind;
226 i += rest. match (hit.re)[ 0 ]. length ;
227 }
228 }
229 let path = null ;
230 if (text. startsWith ( '.json' , i) && accessor !== 'all' && accessor !== 'items' ) {
231 const read = readPath (text, i + '.json' . length );
232 path = read.dynamic ? null : read.path;
233 }
234 refs. push ({ node, kind: 'named' , accessor, path, raw: text. slice (m.index, i + 40 ). split ( / [}\n] / )[ 0 ] });
235 }
236 for ( const m of text. matchAll ( / \$ json \b / g )) {
237 const read = readPath (text, m.index + '$json' . length );
238 refs. push ({ node: null , kind: 'json' , accessor: 'item' , path: read.dynamic ? null : read.path,
239 raw: text. slice (m.index, read.end) });
240 }
241 for ( const m of text. matchAll ( / \$ input \. (item | first \( \s * \) | last \( \s * \) | all \( \s * \)\[ \s * \d + \s * \] ) \. json \b / g )) {
242 const read = readPath (text, m.index + m[ 0 ]. length );
243 const accessor = m[ 1 ]. startsWith ( 'first' ) ? 'first' : m[ 1 ]. startsWith ( 'last' ) ? 'last' : m[ 1 ] === 'item' ? 'item' : 'all-index' ;
244 refs. push ({ node: null , kind: 'input' , accessor, path: read.dynamic ? null : read.path,
245 raw: text. slice (m.index, read.end) });
246 }
247 return refs;
248 }
249
250 /** Every reference in a workflow: { node (the node that holds it), param, ref, code }. */
251 export function workflowReferences ( g ) {
252 const out = [];
253 for ( const node of g.nodes) {
254 for ( const { path , value } of strings (node.parameters ?? {})) {
255 const text = expressionText (path, value);
256 if (text === null ) continue ;
257 const code = ! value. startsWith ( '=' );
258 for ( const ref of references (text)) out. push ({ node: node.name, param: path, ref, code });
259 }
260 }
261 return out;
262 }
263
264 /**
265 * Look up a path in a value. Returns 'found', 'missing' or 'unknown'.
266 * 'unknown' means that a null or an opaque value hides the rest of the path. With sample, the
267 * value comes from a schema, which shows the shape of one array item and not the count, so an
268 * index past the end of an array is 'unknown'. In real output, that index is 'missing'.
269 */
270 export function lookup ( value , path , { sample = false } = {}) {
271 let current = value;
272 for ( const key of path) {
273 if (current === null || current === undefined ) return current === null ? 'unknown' : 'missing' ;
274 if (Array. isArray (current)) {
275 if ( typeof key === 'number' ) {
276 if (key >= current. length ) return sample ? 'unknown' : 'missing' ;
277 current = current[key];
278 } else if (key === 'length' ) return 'found' ;
279 else return 'missing' ;
280 continue ;
281 }
282 if ( typeof current === 'string' ) return key === 'length' ? 'found' : 'missing' ;
283 if ( typeof current !== 'object' ) return 'missing' ;
284 if ( ! (key in current)) return 'missing' ;
285 current = current[key];
286 }
287 return current === undefined ? 'missing' : 'found' ;
288 }
289
290 export const showPath = ( path ) =>
291 path. map (( k , i ) => ( typeof k === 'number' ? `[${ k }]` : / ^ [A-Za-z_$][\w$] *$ / . test (k) ? (i ? `.${ k }` : k) : `['${ k }']` )). join ( '' );
292
293 // --- JSON Schema samples ---------------------------------------------------------
294
295 /** A deterministic sample value for a JSON Schema. key names the field, for readable strings. */
296 export function sample ( schema , key = 'value' , depth = 0 ) {
297 if ( ! schema || typeof schema !== 'object' ) return null ;
298 if (schema.example !== undefined ) return schema.example;
299 if (schema.const !== undefined ) return schema.const;
300 if (Array. isArray (schema.enum) && schema.enum. length ) return schema.enum[ 0 ];
301 if (schema.default !== undefined ) return schema.default;
302 const variant = schema.anyOf ?? schema.oneOf;
303 if (Array. isArray (variant) && variant. length ) return sample (variant. find (( v ) => v.type !== 'null' ) ?? variant[ 0 ], key, depth);
304 let type = Array. isArray (schema.type) ? schema.type. find (( t ) => t !== 'null' ) : schema.type;
305 if ( ! type) type = schema.properties ? 'object' : schema.items ? 'array' : 'string' ;
306 switch (type) {
307 case 'object' : {
308 if (depth > 6 ) return {};
309 return Object. fromEntries (
310 Object. entries (schema.properties ?? {}). map (([ k , v ]) => [k, sample (v, k, depth + 1 )]),
311 );
312 }
313 case 'array' :
314 return schema.items && depth <= 6 ? [ sample (schema.items, key, depth + 1 )] : [];
315 case 'integer' :
316 case 'number' :
317 return 1 ;
318 case 'boolean' :
319 return true ;
320 case 'null' :
321 return null ;
322 default : {
323 const k = String (key);
324 if (schema.format === 'date-time' || /( ^| _)(date | time | at) $| At $ / . test (k)) return '2026-01-01T09:00:00.000Z' ;
325 if (schema.format === 'email' || /email/ i . test (k)) return 'ada@example.com' ;
326 if (schema.format === 'uri' || /url | link | href/ i . test (k)) return `https://example.com/${ k }` ;
327 if ( /( ^| _)id $| Id $ / . test (k)) return `${ k }-1` ;
328 return `sample ${ k }` ;
329 }
330 }
331 }
332
333 /** Set every boolean in a value to flag. */
334 function setBooleans ( value , flag ) {
335 if (Array. isArray (value)) return value. map (( v ) => setBooleans (v, flag));
336 if (value && typeof value === 'object' )
337 return Object. fromEntries (Object. entries (value). map (([ k , v ]) => [k, setBooleans (v, flag)]));
338 return typeof value === 'boolean' ? flag : value;
339 }
340
341 function deepMerge ( base , extra ) {
342 if ( ! base || typeof base !== 'object' || Array. isArray (base) || ! extra || typeof extra !== 'object' ) return extra;
343 const out = { ... base };
344 for ( const [ k , v ] of Object. entries (extra)) out[k] = deepMerge (base[k], v);
345 return out;
346 }
347
348 // --- Pin data ------------------------------------------------------------------
349
350 /**
351 * Trigger output for a scenario, in the shape that the trigger code emits.
352 * WhatsApp: WhatsAppTrigger.node.ts emits `{ ...change.value, field }` for each change,
353 * and change.value is the Cloud API webhook payload of a text message.
354 */
355 export const TRIGGER_FIXTURES = {
356 [ `${ BASE }whatsAppTrigger` ]: [{
357 messaging_product: 'whatsapp' ,
358 metadata: { display_phone_number: '15550001111' , phone_number_id: '106540352242922' },
359 contacts: [{ profile: { name: 'Ada Lovelace' }, wa_id: '4915112345678' }],
360 messages: [{ from: '4915112345678' , id: 'wamid.HBgLNDkxNTExMjM0NTY3OBUCABIYFjNFQjA' , timestamp: '1767258000' ,
361 text: { body: 'What are your opening hours?' }, type: 'text' }],
362 field: 'messages' ,
363 }],
364 [ `${ BASE }webhook` ]: [{ headers: { 'content-type' : 'application/json' }, params: {}, query: {},
365 body: { message: 'Hello' }, webhookUrl: 'http://localhost:5678/webhook/test' , executionMode: 'test' }],
366 [ `${ LC }chatTrigger` ]: [{ sessionId: 'session-1' , action: 'sendMessage' , chatInput: 'Hello' }],
367 [ `${ BASE }manualTrigger` ]: [{}],
368 [ `${ BASE }scheduleTrigger` ]: [{ timestamp: '2026-01-01T09:00:00.000+00:00' , 'Readable date' : 'January 1st 2026, 9:00:00 am' ,
369 'Day of week' : 'Thursday' , Year: '2026' , Month: 'January' , 'Day of month' : '01' , Hour: '09' , Minute: '00' ,
370 Second: '00' , Timezone: 'UTC' }],
371 [ `${ BASE }telegramTrigger` ]: [{ update_id: 1 , message: { message_id: 1 , date: 1767258000 , text: 'Hello' ,
372 chat: { id: 1001 , type: 'private' , first_name: 'Ada' }, from: { id: 1001 , is_bot: false , first_name: 'Ada' } } }],
373 [ `${ BASE }formTrigger` ]: [{ submittedAt: '2026-01-01T09:00:00.000Z' , formMode: 'test' }],
374 };
375
376 const whatsAppMessage = ( from , name , body , id ) => ({
377 messaging_product: 'whatsapp' ,
378 metadata: { display_phone_number: '15550001111' , phone_number_id: '106540352242922' },
379 contacts: [{ profile: { name }, wa_id: from }],
380 messages: [{ from, id, timestamp: '1767258000' , text: { body }, type: 'text' }],
381 field: 'messages' ,
382 });
383
384 /**
385 * Per trigger type: identity is the path of the value that names the sender in one item,
386 * withMessage puts a scenario text into the fixture, and edges are the edge cases that every
387 * workflow with this trigger must survive. derive 'each-item' means that each trigger item
388 * must get its own outgoing request that names its sender.
389 */
390 export const TRIGGER_KITS = {
391 [ `${ BASE }whatsAppTrigger` ]: {
392 identity: [ 'messages' , 0 , 'from' ],
393 withMessage : ( items , text ) => items. map (( i ) => ({ ... i, messages: (i.messages ?? []). map (( m ) => ({ ... m, text: { body: text } })) })),
394 edges: [
395 {
396 name: 'Two messages arrive in one webhook call' ,
397 why: 'WhatsAppTrigger.node.ts emits one item for each change, and Meta can batch several changes in one call' ,
398 derive: 'each-item' ,
399 ai: { booleans: true },
400 trigger: [
401 whatsAppMessage ( '4915112345678' , 'Ada Lovelace' , 'What are your opening hours?' , 'wamid.A1' ),
402 whatsAppMessage ( '4917699887766' , 'Alan Turing' , 'Do you ship abroad?' , 'wamid.B2' ),
403 ],
404 },
405 {
406 name: 'A delivery status update arrives, not a customer message' ,
407 why: 'the messages update also delivers sent, delivered and read statuses, with no messages array' ,
408 expect: { noWrites: true },
409 trigger: [{
410 messaging_product: 'whatsapp' ,
411 metadata: { display_phone_number: '15550001111' , phone_number_id: '106540352242922' },
412 statuses: [{ id: 'wamid.HBgLNDkxNTExMjM0NTY3OBUCABEYEjQ' , status: 'delivered' , timestamp: '1767258060' , recipient_id: '4915112345678' }],
413 field: 'messages' ,
414 }],
415 },
416 ],
417 },
418 };
419
420 /** The trigger items of a scenario: its own trigger, the case trigger, or the fixture with the scenario message. */
421 export function triggerItems ( node , scenario = {}, scenarios = {}) {
422 const explicit = scenario.trigger ?? scenarios.trigger;
423 if (explicit) return { items: Array. isArray (explicit) ? explicit : [explicit], source: 'case' };
424 const fixture = TRIGGER_FIXTURES [node.type];
425 if ( ! fixture) return null ;
426 const kit = TRIGGER_KITS [node.type];
427 if (scenario.message !== undefined && kit) return { items: kit. withMessage (fixture, scenario.message), source: 'case' };
428 return { items: fixture, source: 'fixture' };
429 }
430
431 export const isAiRootNode = ( g , node ) => node.type. startsWith ( LC ) && ! g.subnodes. has (node.name) && g. aiInputs (node.name, 'ai_languageModel' ). length > 0 ;
432
433 /** A node whose output comes from a model: an AI root node or a vendor node such as Google Gemini. */
434 export const isAiNode = ( g , node ) => Boolean (node) && ( isAiRootNode (g, node) || ( AI_VENDOR_TYPES . has (node.type) && ! g.subnodes. has (node.name)));
435
436 function parserSchema ( parser ) {
437 const p = parser?.parameters ?? {};
438 const parse = ( text ) => {
439 try {
440 return typeof text === 'string' ? JSON . parse (text) : text && typeof text === 'object' ? text : null ;
441 } catch {
442 return null ;
443 }
444 };
445 const schemaType = p.schemaType ?? 'fromJson' ;
446 if (schemaType === 'manual' && parse (p.inputSchema)) return { kind: 'schema' , value: parse (p.inputSchema) };
447 if (schemaType !== 'manual' && parse (p.jsonSchemaExample)) return { kind: 'example' , value: parse (p.jsonSchemaExample) };
448 if ( parse (p.jsonSchema)) return { kind: 'schema' , value: parse (p.jsonSchema) };
449 return null ;
450 }
451
452 /** JSON objects that a prompt shows as examples, in order. Objects with a boolean come first. */
453 function promptExamples ( node ) {
454 const found = [];
455 for ( const { value } of strings (node.parameters ?? {})) {
456 for ( let start = value. indexOf ( '{' ); start >= 0 ; start = value. indexOf ( '{' , start + 1 )) {
457 let depth = 0 ;
458 for ( let i = start; i < value. length ; i ++ ) {
459 if (value[i] === '{' ) depth ++ ;
460 else if (value[i] === '}' && -- depth === 0 ) {
461 try {
462 const parsed = JSON . parse (value. slice (start, i + 1 ));
463 if (parsed && typeof parsed === 'object' && ! Array. isArray (parsed) && Object. keys (parsed). length )
464 found. push (parsed);
465 } catch {}
466 break ;
467 }
468 }
469 }
470 }
471 return found. sort (( a , b ) => Number ( hasBoolean (b)) - Number ( hasBoolean (a)));
472 }
473
474 const hasBoolean = ( value ) => Object. values (value). some (( v ) => typeof v === 'boolean' );
475
476 /** Apply the ai settings of a scenario to a model result: every boolean, then explicit fields. */
477 function applyAi ( value , ai ) {
478 let out = typeof ai.booleans === 'boolean' ? setBooleans (value, ai.booleans) : value;
479 if (ai.set && out && typeof out === 'object' ) out = deepMerge (out, ai.set);
480 return out;
481 }
482
483 /**
484 * The output of an AI root node for a scenario. Returns { json, provenance }.
485 * With a structured output parser, the result follows the parser schema. Without one, the
486 * model returns text: ai.text when the scenario sets it, else the first JSON example in the
487 * prompt of the node (the contract that the builder declared), else a plain sentence.
488 */
489 function aiRootOutput ( g , node , ai = {}) {
490 const parserNode = g. aiInputs (node.name, 'ai_outputParser' )[ 0 ];
491 const parser = parserNode ? parserSchema (parserNode) : null ;
492 if (parser) {
493 const parsed = parser.kind === 'schema' ? sample (parser.value, 'output' ) : parser.value;
494 return { json: { output: applyAi (parsed, ai) }, provenance: 'output-parser' };
495 }
496 const wrap = AI_ROOT_PLACEHOLDER [node.type] ?? (( v ) => ({ output: v }));
497 if (ai.text !== undefined ) return { json: wrap (ai.text), provenance: 'case' };
498 const example = promptExamples (node)[ 0 ];
499 if (example) return { json: wrap ( JSON . stringify ( applyAi (example, ai))), provenance: 'prompt-example' };
500 return { json: wrap ( 'Sample answer from the model.' ), provenance: AI_ROOT_PLACEHOLDER [node.type] ? 'ai-root' : 'placeholder' };
501 }
502
503 /**
504 * The pin data of each scenario. Logic nodes run for real. Triggers, service nodes and AI root
505 * nodes get pin data, with the provenance of each node, so a check knows how far to trust it:
506 * fixture, case the shape of real data (trigger code, or the case file)
507 * node-schema the __schema__ file of the node
508 * output-parser the schema of the attached structured output parser
509 * prompt-example a JSON example in the prompt of an AI root node without a parser
510 * ai-root the known output envelope of the AI root node
511 * placeholder, empty nothing describes the output, so the shape is a guess or one empty item
512 */
513 export function pinPlan ( workflow , scenarios = {}, only = null ) {
514 const g = graph (workflow);
515 const sdk = loadSdk ()?.sdk;
516 const list = only ?? (scenarios.scenarios?. length ? scenarios.scenarios : defaultScenarios (g));
517 const plan = { pinned: {}, executed: [], subnodes: [ ... g.subnodes], scenarios: [] };
518 for ( const node of g.nodes) {
519 if ( ! g.subnodes. has (node.name) && LOGIC_TYPES . has (node.type)) plan.executed. push (node.name);
520 }
521 for ( const scenario of list) {
522 const pinData = {};
523 const provenance = {};
524 const byType = mergeByType (scenarios.pinByType, scenario.pinByType);
525 for ( const node of g.nodes) {
526 if (g.subnodes. has (node.name) || LOGIC_TYPES . has (node.type)) continue ;
527 let items;
528 let source;
529 const caseItems = Object. entries (byType). find (([ pattern ]) => typeMatches (node.type, pattern))?.[ 1 ];
530 const explicit = scenario.trigger ?? scenarios.trigger;
531 const trigger = isTrigger (node.type) && (explicit || ! caseItems) ? triggerItems (node, scenario, scenarios) : null ;
532 if (trigger) {
533 items = trigger.items;
534 source = trigger.source;
535 } else if (caseItems) {
536 items = caseItems;
537 source = 'case' ;
538 } else if ( isAiRootNode (g, node) || ( isAiNode (g, node) && AI_ROOT_PLACEHOLDER [node.type])) {
539 const out = aiRootOutput (g, node, scenario.ai);
540 items = [out.json];
541 source = out.provenance;
542 } else {
543 const schema = sdk?. discoverOutputSchemaForNode (node.type, node.typeVersion, node.parameters ?? {});
544 items = [schema ? sample (schema, 'item' ) : {}];
545 source = schema ? 'node-schema' : 'empty' ;
546 }
547 pinData[node.name] = (Array. isArray (items) ? items : [items]). map (( json ) => ({ json }));
548 provenance[node.name] = source;
549 }
550 plan.scenarios. push ({ name: scenario.name, expect: scenario.expect ?? {}, pinData, provenance, byType, triggerNode: scenarioTrigger (g, scenario) });
551 Object. assign (plan.pinned, provenance);
552 }
553 return plan;
554 }
555
556 /**
557 * The trigger that a scenario starts from, or null when the workflow has one trigger. Without
558 * scenario.triggerNode, it is the trigger whose downstream nodes match the most expect.reach types.
559 */
560 function scenarioTrigger ( g , scenario ) {
561 if (g.triggers. length < 2 ) return null ;
562 if (scenario.triggerNode && g.triggers. some (( t ) => t.name === scenario.triggerNode)) return scenario.triggerNode;
563 const reach = scenario.expect?.reach ?? [];
564 const score = ( trigger ) => {
565 const types = [ ... downstream (g, trigger.name)]. map (( name ) => g.byName. get (name)?.type ?? '' );
566 return reach. filter (( pattern ) => types. some (( type ) => typeMatches (type, pattern))). length ;
567 };
568 return g.triggers. reduce (( best , t ) => ( score (t) > score (best) ? t : best)).name;
569 }
570
571 /** Case pin data by node type. A scenario entry replaces a case entry for the same short type. */
572 function mergeByType ( base = {}, extra = {}) {
573 const out = { ... base };
574 for ( const [ pattern , items ] of Object. entries (extra ?? {})) {
575 for ( const key of Object. keys (out)) if ( shortType (key). toLowerCase () === shortType (pattern). toLowerCase ()) delete out[key];
576 out[pattern] = items;
577 }
578 return out;
579 }
580
581 /** Without a case file: one scenario for each value of the booleans that an AI node returns. */
582 function defaultScenarios ( g ) {
583 const hasAi = g.nodes. some (( n ) => g. aiInputs (n.name, 'ai_languageModel' ). length && ! g.subnodes. has (n.name));
584 return hasAi
585 ? [{ name: 'AI flags true' , ai: { booleans: true } }, { name: 'AI flags false' , ai: { booleans: false } }]
586 : [{ name: 'Default run' }];
587 }
588
589 // --- CLI -----------------------------------------------------------------------
590
591 if (process.argv[ 1 ] && resolve (process.argv[ 1 ]) === fileURLToPath ( import . meta .url)) {
592 const [ command , workflowFile , scenariosFile ] = process.argv. slice ( 2 );
593 if (command !== 'pin-plan' || ! workflowFile) {
594 console. error ( 'usage: node sdk.mjs pin-plan <workflow.json> [scenarios.json]' );
595 process. exit ( 2 );
596 }
597 const workflow = JSON . parse ( readFileSync (workflowFile, 'utf8' ));
598 const scenarios = scenariosFile && existsSync (scenariosFile) ? JSON . parse ( readFileSync (scenariosFile, 'utf8' )) : {};
599 process.stdout. write ( JSON . stringify ( pinPlan (workflow, scenarios)));
600 }