refactor(core): Route $fromAI() through typed-RPC dispatcher (#31148)

This commit is contained in:
Danny Martini 2026-05-27 18:36:11 +02:00 committed by GitHub
parent 7f595eef55
commit 3f191bcf7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 242 additions and 0 deletions

View File

@ -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();
});
});

View File

@ -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

View File

@ -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>;

View File

@ -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.
*

View File

@ -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

View File

@ -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;
}