mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 15:27:03 +02:00
refactor(core): Route $fromAI() through typed-RPC dispatcher (#31148)
This commit is contained in:
parent
7f595eef55
commit
3f191bcf7c
|
|
@ -505,3 +505,97 @@ describe('Typed RPC: $items() routes via getItems', () => {
|
|||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Typed RPC: $fromAI() routes via fromAi', () => {
|
||||
let evaluator: ExpressionEvaluator;
|
||||
const caller = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
evaluator = new ExpressionEvaluator({
|
||||
createBridge: () => new IsolatedVmBridge({ timeout: 5000 }),
|
||||
maxCodeCacheSize: 64,
|
||||
});
|
||||
await evaluator.initialize();
|
||||
await evaluator.acquire(caller);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await evaluator.release(caller);
|
||||
await evaluator.dispose();
|
||||
});
|
||||
|
||||
it('returns the resolved value of data.$fromAI(name)', () => {
|
||||
const data: Record<string, unknown> = {
|
||||
$fromAI: (name?: string) => `resolved:${name}`,
|
||||
};
|
||||
|
||||
const result = evaluator.evaluate("{{ $fromAI('placeholder_one') }}", data, caller);
|
||||
expect(result).toBe('resolved:placeholder_one');
|
||||
});
|
||||
|
||||
it('forwards name, description, type, and defaultValue verbatim', () => {
|
||||
const calls: Array<unknown[]> = [];
|
||||
const data: Record<string, unknown> = {
|
||||
$fromAI: (...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
return 'ok';
|
||||
},
|
||||
};
|
||||
|
||||
evaluator.evaluate("{{ $fromAI('a') }}", data, caller);
|
||||
evaluator.evaluate("{{ $fromAI('a', 'description') }}", data, caller);
|
||||
evaluator.evaluate("{{ $fromAI('a', 'description', 'number') }}", data, caller);
|
||||
evaluator.evaluate("{{ $fromAI('a', 'description', 'number', 42) }}", data, caller);
|
||||
|
||||
expect(calls).toEqual([
|
||||
['a', undefined, undefined, undefined],
|
||||
['a', 'description', undefined, undefined],
|
||||
['a', 'description', 'number', undefined],
|
||||
['a', 'description', 'number', 42],
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards arbitrary defaultValue shapes (number, string, boolean, null, object)', () => {
|
||||
// `defaultValue` is `z.unknown()` because the host applies no shape
|
||||
// constraint — it just returns the fallback via `??`. Verify the
|
||||
// bridge structured-clones each shape through to the host.
|
||||
const calls: Array<unknown[]> = [];
|
||||
const data: Record<string, unknown> = {
|
||||
$fromAI: (...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
return 'ok';
|
||||
},
|
||||
};
|
||||
|
||||
evaluator.evaluate("{{ $fromAI('a', '', 'string', 42) }}", data, caller);
|
||||
evaluator.evaluate("{{ $fromAI('a', '', 'string', 'fallback') }}", data, caller);
|
||||
evaluator.evaluate("{{ $fromAI('a', '', 'string', true) }}", data, caller);
|
||||
evaluator.evaluate("{{ $fromAI('a', '', 'string', null) }}", data, caller);
|
||||
evaluator.evaluate("{{ $fromAI('a', '', 'string', { nested: 'value' }) }}", data, caller);
|
||||
|
||||
expect(calls.map((c) => c[3])).toEqual([42, 'fallback', true, null, { nested: 'value' }]);
|
||||
});
|
||||
|
||||
it('$fromAi (mid-case) alias routes through the same handler', () => {
|
||||
const data: Record<string, unknown> = {
|
||||
$fromAI: (name?: string) => `via-aliases:${name}`,
|
||||
};
|
||||
|
||||
expect(evaluator.evaluate("{{ $fromAi('x') }}", data, caller)).toBe('via-aliases:x');
|
||||
});
|
||||
|
||||
it('$fromai (all-lower) alias routes through the same handler', () => {
|
||||
const data: Record<string, unknown> = {
|
||||
$fromAI: (name?: string) => `via-aliases:${name}`,
|
||||
};
|
||||
|
||||
expect(evaluator.evaluate("{{ $fromai('x') }}", data, caller)).toBe('via-aliases:x');
|
||||
});
|
||||
|
||||
it('handles missing data.$fromAI gracefully (returns undefined)', () => {
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
const result = evaluator.evaluate("{{ $fromAI('placeholder') }}", data, caller);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,6 +59,17 @@ describe('bridgeMessageSchema', () => {
|
|||
expect(parsed.type).toBe('getItems');
|
||||
});
|
||||
|
||||
it('parses a valid fromAi envelope', () => {
|
||||
const parsed = bridgeMessageSchema.parse({
|
||||
type: 'fromAi',
|
||||
name: 'placeholder',
|
||||
description: 'A description',
|
||||
valueType: 'string',
|
||||
defaultValue: 'fallback',
|
||||
});
|
||||
expect(parsed.type).toBe('fromAi');
|
||||
});
|
||||
|
||||
it('rejects an unknown discriminator value', () => {
|
||||
expect(() => bridgeMessageSchema.parse({ type: 'evalArbitrary', nodeName: 'Foo' })).toThrow();
|
||||
});
|
||||
|
|
@ -78,6 +89,43 @@ describe('bridgeMessageSchema', () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe('fromAi', () => {
|
||||
it('accepts a minimal envelope (type only)', () => {
|
||||
// `name` is optional in the schema so empty calls reach the host's
|
||||
// friendly `ExpressionError("Add a key, e.g. $fromAI(...)")` rather
|
||||
// than a generic zod error.
|
||||
expect(() => bridgeMessageSchema.parse({ type: 'fromAi' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts arbitrary defaultValue shapes', () => {
|
||||
// defaultValue is z.unknown() — host applies no shape constraint.
|
||||
for (const defaultValue of [42, 'str', true, null, { nested: 1 }, [1, 2]]) {
|
||||
expect(() =>
|
||||
bridgeMessageSchema.parse({ type: 'fromAi', name: 'a', defaultValue }),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects non-string name', () => {
|
||||
expect(() => bridgeMessageSchema.parse({ type: 'fromAi', name: 123 })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-string description / valueType', () => {
|
||||
expect(() =>
|
||||
bridgeMessageSchema.parse({ type: 'fromAi', name: 'a', description: 1 }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
bridgeMessageSchema.parse({ type: 'fromAi', name: 'a', valueType: 1 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects extra fields (.strict)', () => {
|
||||
expect(() =>
|
||||
bridgeMessageSchema.parse({ type: 'fromAi', name: 'a', hijack: 'extra' }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getItems', () => {
|
||||
it('accepts negative runIndex (host -1 sentinel for "latest")', () => {
|
||||
// Unlike branchIndex/runIndex on getNode*, getItems allows negative
|
||||
|
|
|
|||
|
|
@ -93,6 +93,39 @@ export const getItemsMessage = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* `$fromAI(name, description?, type?, defaultValue?)` — the AI-builder
|
||||
* placeholder accessor (aliases: `$fromAi`, `$fromai`).
|
||||
*
|
||||
* Two deliberate looseness points in this schema, both to preserve host
|
||||
* contract / parity:
|
||||
*
|
||||
* 1. `name` is `z.string().optional()` (not required) so a call missing
|
||||
* the argument or passing an empty string reaches the host, which
|
||||
* throws the user-friendly `ExpressionError("Add a key, e.g. $fromAI('placeholder_name')")`.
|
||||
* Requiring it here would replace that error with a generic zod
|
||||
* message. The host also validates the regex `[a-zA-Z0-9_-]{0,64}`;
|
||||
* we don't pre-empt that either.
|
||||
* 2. `defaultValue` is `z.unknown()` because the host accepts any value
|
||||
* as the fallback return (`handleFromAi` returns it directly via
|
||||
* `??`). Structured-clone at the bridge boundary still prevents
|
||||
* functions and other non-cloneable values from crossing.
|
||||
*
|
||||
* `description` and `type` are forwarded even though the host currently
|
||||
* ignores them (`_description`, `_type`), so the protocol matches the
|
||||
* documented call signature.
|
||||
*/
|
||||
export const fromAiMessage = z
|
||||
.object({
|
||||
type: z.literal('fromAi'),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
valueType: z.string().optional(),
|
||||
// `z.unknown()` already accepts `undefined`, so no `.optional()` needed.
|
||||
defaultValue: z.unknown(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The full set of messages the bridge will accept. Discriminator is `type`.
|
||||
*
|
||||
|
|
@ -108,6 +141,7 @@ export const bridgeMessageSchema = z.discriminatedUnion('type', [
|
|||
getInputLastMessage,
|
||||
getInputAllMessage,
|
||||
getItemsMessage,
|
||||
fromAiMessage,
|
||||
]);
|
||||
|
||||
export type BridgeMessage = z.infer<typeof bridgeMessageSchema>;
|
||||
|
|
|
|||
|
|
@ -496,6 +496,8 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
return this.handleGetInputAll(data);
|
||||
case 'getItems':
|
||||
return this.handleGetItems(msg, data);
|
||||
case 'fromAi':
|
||||
return this.handleFromAi(msg, data);
|
||||
default: {
|
||||
// Unreachable at runtime — zod rejects unknown `type` values
|
||||
// before the switch. The `never` assignment is the compile-time
|
||||
|
|
@ -588,6 +590,29 @@ export class IsolatedVmBridge implements RuntimeBridge {
|
|||
return data.$items?.(msg.nodeName, msg.outputIndex, msg.runIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for `$fromAI(name, description?, type?, defaultValue?)` and its
|
||||
* `$fromAi` / `$fromai` aliases. Reads the literal `$fromAI` property
|
||||
* off `data` (host-wired) and forwards the args. The host validates
|
||||
* `name` (required + regex) and applies its own resolution / fallback
|
||||
* logic, so empty / invalid names surface as the host's structured
|
||||
* `ExpressionError` rather than a generic zod parse error.
|
||||
*
|
||||
* Note: `msg.valueType` maps to the host's third positional parameter
|
||||
* (`_type` in `WorkflowDataProxy.handleFromAi`). The bridge protocol
|
||||
* renames it to avoid collision with the `type` discriminator on the
|
||||
* envelope — the host parameter currently goes unused, but if it ever
|
||||
* gains a name (`type`), this mapping should stay explicit.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private handleFromAi(
|
||||
msg: Extract<BridgeMessage, { type: 'fromAi' }>,
|
||||
data: WorkflowData,
|
||||
): unknown {
|
||||
return data.$fromAI?.(msg.name, msg.description, msg.valueType, msg.defaultValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute JavaScript code in the isolated context.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -268,6 +268,30 @@ export function buildContext(
|
|||
return result;
|
||||
};
|
||||
|
||||
// $fromAI / $fromAi / $fromai — AI-builder placeholder accessor.
|
||||
// All three host aliases route to the same `handleFromAi` callback;
|
||||
// the typed-RPC envelope is identical regardless of which name the
|
||||
// expression used. `name` is forwarded as-is — host validates it
|
||||
// (required, regex-restricted) and emits a structured `ExpressionError`
|
||||
// on bad input.
|
||||
const sendFromAi = (
|
||||
name?: string,
|
||||
description?: string,
|
||||
valueType?: string,
|
||||
defaultValue?: unknown,
|
||||
) => {
|
||||
const result = callbacks.callHost.applySync(
|
||||
null,
|
||||
[{ type: 'fromAi', name, description, valueType, defaultValue }],
|
||||
{ arguments: { copy: true }, result: { copy: true } },
|
||||
);
|
||||
throwIfErrorSentinel(result);
|
||||
return result;
|
||||
};
|
||||
target.$fromAI = sendFromAi;
|
||||
target.$fromAi = sendFromAi;
|
||||
target.$fromai = sendFromAi;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Resolve an unknown key from the host. Called by the proxy's has/get traps
|
||||
// for keys not already on the target. The resolved value is cached on target
|
||||
|
|
|
|||
|
|
@ -142,10 +142,27 @@ export interface InputProxy {
|
|||
* primitives (`getValueAtPath`, `getArrayElement`), which read paths off
|
||||
* the index signature without needing per-key types.
|
||||
*/
|
||||
/**
|
||||
* Signature shared by `$fromAI`, `$fromAi`, and `$fromai` — the three
|
||||
* host-side aliases that resolve to the same `handleFromAi` callback in
|
||||
* `WorkflowDataProxy`. The `name` argument is optional in the type so
|
||||
* empty / missing calls reach the host, which throws a friendly
|
||||
* `ExpressionError` rather than a generic zod / runtime error.
|
||||
*/
|
||||
export type FromAi = (
|
||||
name?: string,
|
||||
description?: string,
|
||||
valueType?: string,
|
||||
defaultValue?: unknown,
|
||||
) => unknown;
|
||||
|
||||
export interface WorkflowData {
|
||||
$?: (nodeName: string) => NodeProxy | null | undefined;
|
||||
$input?: InputProxy;
|
||||
$items?: (nodeName?: string, outputIndex?: number, runIndex?: number) => unknown;
|
||||
$fromAI?: FromAi;
|
||||
$fromAi?: FromAi;
|
||||
$fromai?: FromAi;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user