Setting the file. One moment.
Wire · N8n:testmax N8n AI · n8n-io/n8n · Skills Docs
ContentsBack to the top of the page 217
const bodyOf
— line 217
This file
Number 40.22
Position 22 of 24
Type JavaScript
Size 21 KB
Lines 459 scripts/ wire.mjs
JavaScript · 459 lines · 21 KB
import
{ join }
from
'node:path'
;
12
13 import { repo } from './sdk.mjs' ;
14
15 const coreRequire = createRequire ( join (repo, 'packages/core/package.json' ));
16 const PACKAGES = [
17 [ 'n8n-nodes-base' , join (repo, 'packages/nodes-base' )],
18 [ '@n8n/n8n-nodes-langchain' , join (repo, 'packages/@n8n/nodes-langchain' )],
19 ];
20 const REQUIRED_DIST = [
21 'packages/core/dist/execution-engine/index.js' ,
22 'packages/nodes-base/dist/known/nodes.json' ,
23 'packages/@n8n/nodes-langchain/dist/known/nodes.json' ,
24 ];
25
26 // --- Settings ------------------------------------------------------------------
27
28 // Same order as n8n_api.config(): .env.local, then .env.ai-surfaces, then the environment.
29 const fileSettings = {};
30 for ( const file of [ join (repo, '.env.local' ), process.env. AI_SURFACES_ENV ?? join (repo, '.env.ai-surfaces' )]) {
31 if ( ! existsSync (file)) continue ;
32 for ( const line of readFileSync (file, 'utf8' ). split ( ' \n ' )) {
33 const match = line. match ( / ^ \s * (?:export \s + ) ? ( [A-Z0-9_] + ) \s * = \s * ( . * ) $ / );
34 const value = match?.[ 2 ]. trim (). replace ( / ^ ( ['"] )( . * ) \1$ / , '$2' );
35 if (value) fileSettings[match[ 1 ]] = value;
36 }
37 }
38 export const setting = ( name ) => process.env[name] || fileSettings[name] || '' ;
39
40 /** Model credential types, the settings that can hold their key, and the hosts of their API. */
41 export const MODEL_CREDENTIALS = {
42 googlePalmApi: { keys: [ 'GOOGLE_GENERATIVE_AI_API_KEY' , 'GEMINI_API_KEY' , 'GOOGLE_API_KEY' ], field: 'apiKey' , hosts: [ 'generativelanguage.googleapis.com' ] },
43 openAiApi: { keys: [ 'OPENAI_API_KEY' ], field: 'apiKey' , hosts: [ 'api.openai.com' ] },
44 anthropicApi: { keys: [ 'ANTHROPIC_API_KEY' ], field: 'apiKey' , hosts: [ 'api.anthropic.com' ] },
45 mistralCloudApi: { keys: [ 'MISTRAL_API_KEY' ], field: 'apiKey' , hosts: [ 'api.mistral.ai' ] },
46 groqApi: { keys: [ 'GROQ_API_KEY' ], field: 'apiKey' , hosts: [ 'api.groq.com' ] },
47 };
48 export const modelKey = ( type ) => MODEL_CREDENTIALS [type]?.keys. map (setting). find (Boolean) ?? '' ;
49
50 // --- Engine --------------------------------------------------------------------
51
52 let engine;
53
54 /** The execution engine, node types and credential types. Returns { error } when a dist folder is missing. */
55 export function loadEngine () {
56 if (engine) return engine;
57 const missing = REQUIRED_DIST . filter (( p ) => ! existsSync ( join (repo, p)));
58 if (missing. length ) return { error: `Build these first: ${ missing . join ( ', ' ) }.` };
59 coreRequire ( 'reflect-metadata' );
60 const workflowLib = coreRequire ( 'n8n-workflow' );
61 const core = coreRequire ( join (repo, 'packages/core/dist/execution-engine/index.js' ));
62 const nodes = new Map ();
63 const credentials = new Map ();
64 for ( const [ prefix , dir ] of PACKAGES ) {
65 const read = ( file ) => JSON . parse ( readFileSync ( join (dir, 'dist/known' , file), 'utf8' ));
66 for ( const [ name , info ] of Object. entries ( read ( 'nodes.json' ))) nodes. set ( `${ prefix }.${ name }` , { ... info, dir });
67 for ( const [ name , info ] of Object. entries ( read ( 'credentials.json' ))) credentials. set (name, { ... info, dir });
68 }
69 const instances = new Map ();
70 const instance = ( index , name ) => {
71 const key = `${ index === nodes ? 'n' : 'c'}:${ name }` ;
72 if ( ! instances. has (key)) {
73 const info = index. get (name);
74 if ( ! info) throw new Error ( `Unknown type ${ name }` );
75 const mod = createRequire ( join (info.dir, 'package.json' ))( join (info.dir, info.sourcePath));
76 instances. set (key, new mod[info.className]());
77 }
78 return instances. get (key);
79 };
80 const nodeTypes = {
81 getByName : ( type ) => instance (nodes, type),
82 getByNameAndVersion : ( type , version ) => {
83 const loaded = instance (nodes, type);
84 const versioned = 'nodeVersions' in loaded ? workflowLib.NodeHelpers. getVersionedNodeType (loaded, version) : loaded;
85 // The same rule as shouldAssignExecuteMethod in packages/cli/src/utils.ts. Without it a
86 // declarative node with a webhook (WhatsApp send and wait) gets no input data for $json.
87 const declarative = versioned.description?.requestDefaults !== undefined ;
88 if ( ! versioned.execute && ! versioned.supplyData && ! versioned.poll && ! versioned.trigger && ( ! versioned.webhook || declarative) && ( ! versioned.methods || declarative)) {
89 versioned. execute = async function () {
90 return ( await new core. RoutingNode ( this , versioned). runNode ()) ?? [];
91 };
92 }
93 return versioned;
94 },
95 getKnownTypes : () => ({}),
96 };
97 const credentialType = ( name ) => instance (credentials, name);
98 const parentTypes = ( name ) => {
99 try {
100 return ( credentialType (name).extends ?? []). flatMap (( p ) => [p, ... parentTypes (p)]);
101 } catch {
102 return [];
103 }
104 };
105 const credentialProperties = ( name ) => [ ... ( credentialType (name).extends ?? []). flatMap (credentialProperties), ... ( credentialType (name).properties ?? [])];
106 engine = { workflowLib, core, nodeTypes, nodes, credentialType, parentTypes, credentialProperties };
107 return engine;
108 }
109
110 /** Credential types that a node can use, from its node type description. */
111 export function nodeCredentialTypes ( node ) {
112 try {
113 return ( loadEngine ().nodeTypes. getByNameAndVersion (node.type, node.typeVersion).description.credentials ?? []). map (( c ) => c.name);
114 } catch {
115 return [];
116 }
117 }
118
119 /** Apply the generic authenticate rule of a credential type, such as `qs.key` of Google Gemini. */
120 function applyGenericAuth ( credentialType , credentials , request ) {
121 const auth = credentialType.authenticate;
122 if ( ! auth || typeof auth !== 'object' || auth.type !== 'generic' ) return request;
123 const fill = ( v ) => ( typeof v === 'string' ? v. replace ( / ^ =/ , '' ). replace ( / \{\{ \s * \$ credentials \. ( \w + ) \s * \}\} / g , ( _m , k ) => credentials[k] ?? '' ) : v);
124 for ( const part of [ 'headers' , 'qs' , 'body' ]) {
125 const props = auth.properties?.[part];
126 if (props) request[part] = { ... (request[part] ?? {}), ... Object. fromEntries (Object. entries (props). map (([ k , v ]) => [k, fill (v)])) };
127 }
128 return request;
129 }
130
131 function credentialsHelper ( realModels ) {
132 const { core , parentTypes , credentialProperties , credentialType } = loadEngine ();
133 return {
134 getParentTypes: parentTypes,
135 getDecrypted : async ( _data , _details , type ) => {
136 const mocked = core. buildEvalMockCredentials ( credentialProperties (type));
137 const model = MODEL_CREDENTIALS [type];
138 if (realModels && model) mocked[model.field] = modelKey (type);
139 return mocked;
140 },
141 // Vendor model nodes send their request through the n8n helpers, so the real key must be applied here.
142 authenticate : async ( credentials , type , request ) => (realModels && MODEL_CREDENTIALS [type] ? applyGenericAuth ( credentialType (type), credentials, request) : request),
143 preAuthentication : async () => undefined ,
144 runPreAuthentication : async () => undefined ,
145 getCredentials : async () => ({}),
146 getCredentialsProperties : ( type ) => credentialProperties (type),
147 updateCredentials : async () => {},
148 updateCredentialsOauthTokenData : async () => {},
149 isCredentialUsableByNode : () => true ,
150 };
151 }
152
153 // --- Setup values ----------------------------------------------------------------
154
155 /** Values that a node accepts for a parameter that the builder left for setup. */
156 const SETUP_VALUES = {
157 'googleSheets.sheetName' : 'gid=0' ,
158 };
159
160 const isEmptyValue = ( v ) => v === undefined || v === null || v === '' ;
161
162 /**
163 * A copy of the workflow with placeholder values in the parameters that the builder left for
164 * the user: empty resource locators, and empty parameters that the editor reports as required.
165 * Returns { workflow, filled }.
166 */
167 export function fillSetup ( workflow , canvas ) {
168 const copy = structuredClone (workflow);
169 const filled = [];
170 const required = new Map ();
171 for ( const node of canvas?.nodes ?? []) {
172 for ( const [ param , messages ] of Object. entries (node.issues?.parameters ?? {})) {
173 if (messages. some (( m ) => /is required/ i . test (m))) required. set ( `${ node . name }|${ param }` , true );
174 }
175 }
176 for ( const node of copy.nodes ?? []) {
177 const short = node.type. slice (node.type. lastIndexOf ( '.' ) + 1 );
178 for ( const [ param , value ] of Object. entries (node.parameters ?? {})) {
179 const placeholder = SETUP_VALUES [ `${ short }.${ param }` ] ?? `mock-${ param }` ;
180 if (value && typeof value === 'object' && value.__rl && isEmptyValue (value.value)) {
181 node.parameters[param] = { ... value, value: placeholder };
182 filled. push ( `${ node . name } › ${ param }` );
183 } else if ( isEmptyValue (value) && required. has ( `${ node . name }|${ param }` )) {
184 node.parameters[param] = placeholder;
185 filled. push ( `${ node . name } › ${ param }` );
186 }
187 }
188 }
189 return { workflow: copy, filled };
190 }
191
192 // --- Wire responses --------------------------------------------------------------
193
194 const WRITE_METHODS = new Set ([ 'POST' , 'PUT' , 'PATCH' , 'DELETE' ]);
195 export const isWrite = ( request ) => WRITE_METHODS . has (request.method);
196
197 /** Rows for a read node: [{ Question, Answer }, ...] become the header row and the value rows. */
198 function sheetValues ( rows ) {
199 const headers = [ ...new Set (rows. flatMap (( r ) => Object. keys (r ?? {})))];
200 if ( ! headers. length ) return [[ 'Question' , 'Answer' ]];
201 return [headers, ... rows. map (( r ) => headers. map (( h ) => (r?.[h] === undefined ? '' : String (r[h]))))];
202 }
203
204 /** Page properties as Notion returns them: each with an id, a type, and plain_text in rich text. */
205 function notionProperties ( sent = {}) {
206 const out = {};
207 for ( const [ name , value ] of Object. entries (sent ?? {})) {
208 const type = value?.type ?? Object. keys (value ?? {}). find (( k ) => k !== 'id' && k !== 'type' ) ?? 'rich_text' ;
209 let content = value?.[type];
210 if (Array. isArray (content)) content = content. map (( t ) => ({ type: 'text' , ... t, plain_text: t?.plain_text ?? t?.text?.content ?? '' }));
211 out[name] = { id: `prop-${ Object . keys ( out ). length }` , type, [type]: content ?? null };
212 }
213 if ( ! Object. values (out). some (( p ) => p.type === 'title' )) out.Name = { id: 'title' , type: 'title' , title: [] };
214 return out;
215 }
216
217 const bodyOf = ( request ) => {
218 if ( typeof request.body !== 'string' ) return request.body ?? {};
219 try {
220 return JSON . parse (request.body);
221 } catch {
222 return {};
223 }
224 };
225
226 /**
227 * Responses in the shape that each API documents. A fixture matches on host, method and path.
228 * rows(node) gives the case rows for a read node (scenarios.json pinByType).
229 */
230 const FIXTURES = [
231 {
232 service: 'Google Sheets' ,
233 host: /( ^| \. )sheets \. googleapis \. com $ / ,
234 method: 'GET' ,
235 path: / ^ \/ v4 \/ spreadsheets \/ [ ^ /] +$ / ,
236 respond : () => ({ spreadsheetId: 'mock-sheet' , properties: { title: 'FAQ' }, sheets: [{ properties: { sheetId: 0 , title: 'Sheet1' , index: 0 , gridProperties: { rowCount: 100 , columnCount: 26 } } }] }),
237 },
238 {
239 service: 'Google Sheets' ,
240 host: /( ^| \. )sheets \. googleapis \. com $ / ,
241 method: 'GET' ,
242 path: / ^ \/ v4 \/ spreadsheets \/ [ ^ /] + \/ values \/ / ,
243 respond : ( _r , ctx , node ) => ({ range: 'Sheet1!A1:Z100' , majorDimension: 'ROWS' , values: sheetValues (ctx. rows (node) ?? []) }),
244 },
245 {
246 service: 'Google Sheets' ,
247 host: /( ^| \. )sheets \. googleapis \. com $ / ,
248 method: 'POST' ,
249 path: /:append $| values:batchUpdate $| :batchUpdate $ / ,
250 respond : () => ({ spreadsheetId: 'mock-sheet' , updates: { updatedRange: 'Sheet1!A2:B2' , updatedRows: 1 }, replies: [] }),
251 },
252 {
253 service: 'Notion' ,
254 host: /( ^| \. )api \. notion \. com $ / ,
255 method: 'GET' ,
256 path: / ^ \/ v1 \/ (databases | data_sources) \/ [ ^ /] +$ / ,
257 respond : ( r ) => ({
258 object: r.url. includes ( 'data_sources' ) ? 'data_source' : 'database' ,
259 id: 'mock-database' ,
260 title: [{ type: 'text' , plain_text: 'Support tickets' , text: { content: 'Support tickets' } }],
261 properties: { Name: { id: 'title' , name: 'Name' , type: 'title' , title: {} } },
262 data_sources: [{ id: 'mock-data-source' , name: 'Support tickets' }],
263 }),
264 },
265 {
266 service: 'Notion' ,
267 host: /( ^| \. )api \. notion \. com $ / ,
268 method: 'POST' ,
269 path: / ^ \/ v1 \/ pages $ / ,
270 respond : ( r ) => {
271 const body = bodyOf (r);
272 return { object: 'page' , id: '59833787-2cf9-4fdf-8782-e53db20768a5' , created_time: '2026-01-01T09:00:00.000Z' , last_edited_time: '2026-01-01T09:00:00.000Z' , archived: false , in_trash: false , parent: body.parent ?? {}, properties: notionProperties (body.properties), url: 'https://www.notion.so/Ticket-598337872cf94fdf8782e53db20768a5' , public_url: null };
273 },
274 },
275 {
276 service: 'Notion' ,
277 host: /( ^| \. )api \. notion \. com $ / ,
278 method: 'PATCH' ,
279 path: / ^ \/ v1 \/ blocks \/ [ ^ /] + \/ children $ / ,
280 respond : ( r ) => ({ object: 'list' , results: ( bodyOf (r).children ?? []). map (( c , i ) => ({ object: 'block' , id: `mock-block-${ i }` , ... c })), next_cursor: null , has_more: false }),
281 },
282 {
283 service: 'Slack' ,
284 host: /( ^| \. )slack \. com $ / ,
285 method: 'POST' ,
286 path: / ^ \/ api \/ chat \. postMessage $ / ,
287 respond : ( r ) => {
288 const body = bodyOf (r);
289 return { ok: true , channel: body.channel ?? 'C0000000000' , ts: '1767258000.000100' , message: { type: 'message' , text: body.text ?? '' , ts: '1767258000.000100' , bot_id: 'B0000000000' } };
290 },
291 },
292 {
293 service: 'WhatsApp Cloud API' ,
294 host: /( ^| \. )graph \. facebook \. com $ / ,
295 method: 'POST' ,
296 path: / ^ \/ v \d + ( \. \d + ) ? \/ [ ^ /] + \/ messages $ / ,
297 respond : ( r ) => {
298 const to = bodyOf (r).to ?? '' ;
299 return { messaging_product: 'whatsapp' , contacts: [{ input: to, wa_id: to }], messages: [{ id: 'wamid.HBgLMTU1NTAwMDExMTEVAgARGBI5QTNDQTVCM0Q0Q0Q2RTY3RTcA' }] };
300 },
301 },
302 {
303 service: 'Telegram Bot API' ,
304 host: /( ^| \. )api \. telegram \. org $ / ,
305 method: 'POST' ,
306 path: / \/ sendMessage $ / ,
307 respond : ( r ) => ({ ok: true , result: { message_id: 2 , date: 1767258000 , chat: { id: bodyOf (r).chat_id ?? 1001 , type: 'private' }, text: bodyOf (r).text ?? '' } }),
308 },
309 {
310 service: 'Gmail' ,
311 host: /( ^| \. )gmail \. googleapis \. com $| ( ^| \. )www \. googleapis \. com $ / ,
312 method: 'POST' ,
313 path: / \/ messages \/ send $ / ,
314 respond : () => ({ id: 'mock-message' , threadId: 'mock-thread' , labelIds: [ 'SENT' ] }),
315 },
316 ];
317
318 /**
319 * A request handler for evalLlmMockHandler. Every request is recorded in ctx.requests with the
320 * fixture that answered it. A request with no fixture gets a generic answer: a read gets an
321 * empty object, a write gets its own body back with an id. A request to a host in passHosts
322 * goes to the network: these are the model APIs of the real-AI scenarios.
323 */
324 export function wireHandler ( ctx , passHosts = new Set ()) {
325 return async ( request , node ) => {
326 const url = new URL (request.url ?? '' , 'https://unknown.invalid' );
327 if (request.baseURL && ! / ^ https ? :/ . test (request.url ?? '' )) {
328 const base = new URL (request.baseURL);
329 url.host = base.host;
330 url.pathname = `${ base . pathname . replace ( / \/ $ / , '' ) }/${ ( request . url ?? '' ). replace ( / ^ \/ / , '' ) }` ;
331 }
332 const method = String (request.method ?? 'GET' ). toUpperCase ();
333 if (passHosts. has (url.hostname)) {
334 ctx.requests. push ({ node: node.name, nodeType: node.type, method, url: url. toString (), body: request.body, answeredBy: 'real model' });
335 return undefined ;
336 }
337 const fixture = FIXTURES . find (( f ) => f.host. test (url.hostname) && f.method === method && f.path. test (url.pathname));
338 const record = { node: node.name, nodeType: node.type, method, url: url. toString (), qs: request.qs, body: request.body, answeredBy: fixture?.service ?? 'generic' };
339 let body;
340 try {
341 body = fixture ? fixture. respond ({ ... request, url: url. toString () }, ctx, node) : method === 'GET' ? {} : { id: 'mock-1' , ... ( bodyOf (request) ?? {}) };
342 } catch (error) {
343 body = { error: String (error) };
344 }
345 ctx.requests. push ({ ... record, response: body });
346 return { statusCode: 200 , headers: { 'content-type' : 'application/json' }, body };
347 };
348 }
349
350 // --- Network guard ---------------------------------------------------------------
351
352 /**
353 * Replace global fetch while a run is active. Model SDKs use fetch directly, so this is the
354 * only path that could reach the network. Only the hosts in allow pass, the rest is blocked.
355 */
356 function guardFetch ( allow , blocked ) {
357 const original = globalThis.fetch;
358 globalThis. fetch = async ( input , init ) => {
359 const url = new URL ( typeof input === 'string' ? input : input.url ?? String (input));
360 if (allow. has (url.hostname)) return await original (input, init);
361 blocked. push ( `${ init ?. method ?? 'GET'} ${ url . origin }${ url . pathname }` );
362 throw new Error ( `Blocked network request to ${ url . hostname } during a test run.` );
363 };
364 return () => {
365 globalThis.fetch = original;
366 };
367 }
368
369 // --- Code nodes ------------------------------------------------------------------
370
371 /**
372 * additionalData.startRunnerTask. The n8n server sends Code node jobs to a sandboxed task runner.
373 * This process has no sandbox, so it never runs builder code. run_wire.mjs skips workflows with a
374 * Code node. This refusal catches a node type that it does not know.
375 */
376 async function startRunnerTask ( _data , jobType ) {
377 return { ok: false , error: { message: `${ jobType } code does not run in the in-process run.` } };
378 }
379
380 // --- Run -------------------------------------------------------------------------
381
382 const CANCEL_GRACE_MS = 15_000 ;
383
384 /** The value of promise, or null after ms. */
385 async function within ( promise , ms ) {
386 let timer;
387 try {
388 return await Promise . race ([promise, new Promise (( resolve ) => (timer = setTimeout (() => resolve ( null ), ms)))]);
389 } finally {
390 clearTimeout (timer);
391 }
392 }
393
394 /** The pinned trigger that a run starts from: triggerNode, or else the first pinned trigger. */
395 export const startNode = ( workflow , pinData , triggerNode ) =>
396 workflow.nodes. find (( n ) => ( ! triggerNode || n.name === triggerNode) && Object. hasOwn (pinData, n.name) && ! n.disabled && Object. keys (workflow.connections ?? {}). includes (n.name) && /trigger | webhook/ i . test (n.type));
397
398 /**
399 * Run a workflow once. pinData holds the trigger and any pinned nodes. triggerNode names the
400 * trigger to start from. With realModels, model sub-nodes get the real key and fetch can reach
401 * their API hosts. Returns { status, error, runData, requests, blocked, ms }.
402 */
403 export async function runWorkflow ( workflow , { pinData , triggerNode , rows = () => undefined , realModels = false , timeoutMs = 90_000 }) {
404 const { workflowLib , core , nodeTypes } = loadEngine ();
405 const started = Date. now ();
406 const ctx = { requests: [], rows };
407 const blocked = [];
408 const allow = new Set (realModels ? Object. values ( MODEL_CREDENTIALS ). flatMap (( m ) => m.hosts) : []);
409 const restore = guardFetch (allow, blocked);
410 let stillRunning = false ;
411 try {
412 const instance = new workflowLib. Workflow ({ id: 'wire-run' , name: workflow.name, nodes: workflow.nodes, connections: workflow.connections, nodeTypes, active: false , settings: workflow.settings ?? {} });
413 const start = startNode (workflow, pinData, triggerNode);
414 if ( ! start) return { status: 'error' , error: 'No pinned trigger to start from.' , runData: {}, requests: [], blocked, ms: 0 };
415 const hooks = new core. ExecutionLifecycleHooks ( 'manual' , 'wire-run' , workflow);
416 const finished = new Promise (( resolve ) => hooks. addHandler ( 'workflowExecuteAfter' , ( run ) => resolve (run)));
417 const values = {
418 executionId: 'wire-run' ,
419 hooks,
420 credentialsHelper: credentialsHelper (realModels),
421 evalLlmMockHandler: wireHandler (ctx, allow),
422 startRunnerTask,
423 getRunnerStatus : () => ({ available: false }),
424 variables: {},
425 restApiUrl: 'http://localhost:5678/rest' ,
426 instanceBaseUrl: 'http://localhost:5678' ,
427 webhookBaseUrl: 'http://localhost:5678/webhook' ,
428 webhookTestBaseUrl: 'http://localhost:5678/webhook-test' ,
429 webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting' ,
430 formWaitingBaseUrl: 'http://localhost:5678/form-waiting' ,
431 userId: 'wire-run-user' ,
432 };
433 // Unset fields stay undefined. Methods that n8n calls for logs and UI updates do nothing.
434 const additionalData = new Proxy ({}, {
435 get : ( _t , prop ) => (prop in values ? values[prop] : typeof prop === 'string' && / ^ (log | send | set | on | emit | track)/ . test (prop) ? () => undefined : undefined ),
436 });
437 const runExecutionData = workflowLib. createRunExecutionData ({
438 executionData: { nodeExecutionStack: [{ node: start, data: { main: [pinData[start.name]] }, source: null }] },
439 resultData: { pinData },
440 });
441 const execute = new core. WorkflowExecute (additionalData, 'manual' , runExecutionData);
442 const execution = execute. processRunExecutionData (instance);
443 const run = await within (execution. then (() => finished), timeoutMs);
444 if ( ! run) {
445 // Cancel aborts the running node. Wait for the engine to stop, so no request runs after the guard is gone.
446 execution. cancel ();
447 stillRunning = ! ( await within (execution. then (() => true , () => true ), CANCEL_GRACE_MS ));
448 const error = `No result after ${ timeoutMs / 1000 } s.${ stillRunning ? ' The run did not stop after the cancel.' : ''}` ;
449 return { status: 'timeout' , error, runData: {}, requests: ctx.requests, blocked, ms: Date. now () - started };
450 }
451 const error = run.data?.resultData?.error;
452 return { status: run.status, error: error ? error.message ?? String (error) : null , runData: run.data?.resultData?.runData ?? {}, requests: ctx.requests, blocked, ms: Date. now () - started };
453 } catch (error) {
454 return { status: 'error' , error: `${ error ?. name ?? 'Error'}: ${ error ?. message ?? error }` , runData: {}, requests: ctx.requests, blocked, ms: Date. now () - started };
455 } finally {
456 // A run that did not stop keeps the guard. The process exits when all runs are done.
457 if ( ! stillRunning) restore ();
458 }
459 }