Skip to content

Commit 62c8e96

Browse files
committed
feat: inline node dispatch — 13 built-in nodes (math, json, string, flow) run in-process
- Add 'runtime' column (http|inline) to platform_function_definitions - Split doWork() into doWorkInline() and doWorkHttp() paths - InlineNodeRegistry with 13 implementations: math/add, math/multiply, math/subtract, const/number, const/string, const/boolean, json/select, json/object, json/merge, json/split, string/template, flow/guard, coerce - 23 unit tests for inline node implementations - register-functions.ts includes inline nodes in seed SQL - FlowsPanel MOCK_FUNCTIONS show inline nodes with Zap badge - pgpm runtime column with deploy/revert/verify scripts
1 parent 3cf3473 commit 62c8e96

23 files changed

Lines changed: 1007 additions & 23 deletions

File tree

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Unit tests for the inline node registry and implementations.
3+
*/
4+
5+
import { executeInline, getInlineImpl, listInlineNodes } from '../src/inline';
6+
7+
describe('inline node registry', () => {
8+
test('lists all registered nodes', () => {
9+
const nodes = listInlineNodes();
10+
expect(nodes.length).toBeGreaterThanOrEqual(13);
11+
const names = nodes.map(n => n.name);
12+
expect(names).toContain('math/add');
13+
expect(names).toContain('math/multiply');
14+
expect(names).toContain('math/subtract');
15+
expect(names).toContain('json/select');
16+
expect(names).toContain('json/merge');
17+
expect(names).toContain('json/split');
18+
expect(names).toContain('string/template');
19+
expect(names).toContain('flow/guard');
20+
expect(names).toContain('coerce');
21+
});
22+
23+
test('getInlineImpl returns function for registered nodes', () => {
24+
expect(getInlineImpl('math/add')).toBeTruthy();
25+
expect(getInlineImpl('unknown/node')).toBeNull();
26+
});
27+
28+
test('throws for unregistered node', async () => {
29+
await expect(executeInline('nonexistent', {})).rejects.toThrow(
30+
'No inline implementation for "nonexistent"'
31+
);
32+
});
33+
});
34+
35+
describe('math nodes', () => {
36+
test('add', async () => {
37+
const result = await executeInline('math/add', { a: 5, b: 3 });
38+
expect(result).toEqual({ sum: 8 });
39+
});
40+
41+
test('add with missing inputs defaults to 0', async () => {
42+
const result = await executeInline('math/add', {});
43+
expect(result).toEqual({ sum: 0 });
44+
});
45+
46+
test('multiply', async () => {
47+
const result = await executeInline('math/multiply', { a: 4, b: 7 });
48+
expect(result).toEqual({ product: 28 });
49+
});
50+
51+
test('subtract', async () => {
52+
const result = await executeInline('math/subtract', { a: 10, b: 3 });
53+
expect(result).toEqual({ difference: 7 });
54+
});
55+
});
56+
57+
describe('const nodes', () => {
58+
test('const/number', async () => {
59+
const result = await executeInline('const/number', {}, { value: 42 });
60+
expect(result).toEqual({ value: 42 });
61+
});
62+
63+
test('const/string', async () => {
64+
const result = await executeInline('const/string', {}, { value: 'hello' });
65+
expect(result).toEqual({ value: 'hello' });
66+
});
67+
68+
test('const/boolean', async () => {
69+
const result = await executeInline('const/boolean', {}, { value: true });
70+
expect(result).toEqual({ value: true });
71+
});
72+
});
73+
74+
describe('json nodes', () => {
75+
test('json/select extracts nested value', async () => {
76+
const result = await executeInline(
77+
'json/select',
78+
{ obj: { user: { name: 'Dan', age: 30 } } },
79+
{ path: 'user.name' }
80+
);
81+
expect(result).toEqual({ value: 'Dan' });
82+
});
83+
84+
test('json/select returns undefined for missing path', async () => {
85+
const result = await executeInline(
86+
'json/select',
87+
{ obj: { a: 1 } },
88+
{ path: 'b.c' }
89+
);
90+
expect(result).toEqual({ value: undefined });
91+
});
92+
93+
test('json/select handles array index', async () => {
94+
const result = await executeInline(
95+
'json/select',
96+
{ obj: { items: ['a', 'b', 'c'] } },
97+
{ path: 'items.1' }
98+
);
99+
expect(result).toEqual({ value: 'b' });
100+
});
101+
102+
test('json/object builds from inputs', async () => {
103+
const result = await executeInline('json/object', { x: 1, y: 'hello' });
104+
expect(result).toEqual({ value: { x: 1, y: 'hello' } });
105+
});
106+
107+
test('json/merge combines two objects', async () => {
108+
const result = await executeInline('json/merge', {
109+
a: { x: 1, y: 2 },
110+
b: { y: 3, z: 4 },
111+
});
112+
expect(result).toEqual({ value: { x: 1, y: 3, z: 4 } });
113+
});
114+
115+
test('json/split separates by keys', async () => {
116+
const result = await executeInline(
117+
'json/split',
118+
{ obj: { a: 1, b: 2, c: 3 } },
119+
{ keys: ['a', 'c'] }
120+
);
121+
expect(result).toEqual({
122+
selected: { a: 1, c: 3 },
123+
rest: { b: 2 },
124+
});
125+
});
126+
});
127+
128+
describe('string nodes', () => {
129+
test('string/template replaces placeholders', async () => {
130+
const result = await executeInline(
131+
'string/template',
132+
{ name: 'Dan', role: 'engineer' },
133+
{ template: 'Hello {{name}}, you are a {{role}}!' }
134+
);
135+
expect(result).toEqual({ value: 'Hello Dan, you are a engineer!' });
136+
});
137+
138+
test('string/template preserves unresolved placeholders', async () => {
139+
const result = await executeInline(
140+
'string/template',
141+
{ name: 'Dan' },
142+
{ template: 'Hi {{name}}, your id is {{id}}' }
143+
);
144+
expect(result).toEqual({ value: 'Hi Dan, your id is {{id}}' });
145+
});
146+
});
147+
148+
describe('flow nodes', () => {
149+
test('flow/guard passes when ok', async () => {
150+
const result = await executeInline('flow/guard', { ok: true });
151+
expect(result.pass).toBe(true);
152+
expect(result.fail).toBe(false);
153+
});
154+
155+
test('flow/guard fails when not ok', async () => {
156+
const result = await executeInline('flow/guard', { ok: false });
157+
expect(result.pass).toBe(false);
158+
expect(result.fail).toBe(true);
159+
});
160+
161+
test('coerce to number', async () => {
162+
const result = await executeInline('coerce', { value: '42' }, { type: 'number' });
163+
expect(result).toEqual({ value: 42 });
164+
});
165+
166+
test('coerce to string', async () => {
167+
const result = await executeInline('coerce', { value: 42 }, { type: 'string' });
168+
expect(result).toEqual({ value: '42' });
169+
});
170+
171+
test('coerce to boolean', async () => {
172+
const result = await executeInline('coerce', { value: 1 }, { type: 'boolean' });
173+
expect(result).toEqual({ value: true });
174+
});
175+
});

job/compute-worker/src/discovery.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ const COLUMNS = `
2121
id, name, task_identifier, service_url,
2222
is_invocable, is_built_in, max_attempts,
2323
priority, queue_name, scope, namespace_id,
24-
required_configs, required_secrets, description`;
24+
required_configs, required_secrets, description,
25+
runtime`;
2526

2627
export class FunctionDiscovery {
2728
private cache: TtlCache<PlatformFunctionDefinition | null>;

job/compute-worker/src/index.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ import type { Pool, PoolClient } from 'pg';
2121
import { BillingTracker } from './billing';
2222
import { ComputeLogTracker } from './compute-log';
2323
import { FunctionDiscovery } from './discovery';
24+
import { executeInline, getInlineImpl } from './inline';
2425
import { InvocationTracker } from './invocation';
2526
import { ComputeModuleLoader } from './module-loader';
2627
import { compute_request } from './req';
27-
import type { ComputeJobRow, ComputeWorkerOptions } from './types';
28+
import type { ComputeJobRow, ComputeWorkerOptions, PlatformFunctionDefinition } from './types';
2829
import { isGraphNodePayload } from './types';
2930

3031
const DEFAULT_DATABASE_ID = '00000000-0000-0000-0000-000000000000';
@@ -34,6 +35,8 @@ export { TtlCache } from './cache';
3435
export type { ComputeLogEntry } from './compute-log';
3536
export { ComputeLogTracker } from './compute-log';
3637
export { FunctionDiscovery } from './discovery';
38+
export { executeInline, getInlineImpl, listInlineNodes } from './inline';
39+
export type { InlineImplFn, InlineNodeDef } from './inline';
3740
export { InvocationTracker } from './invocation';
3841
export { ComputeModuleLoader } from './module-loader';
3942
export type { ComputeRequestOptions, ComputeRequestResult } from './req';
@@ -48,6 +51,7 @@ export type {
4851
CreateInvocationInput,
4952
FunctionModuleConfig,
5053
FunctionRequirement,
54+
FunctionRuntime,
5155
GraphNodePayload,
5256
InvocationModuleConfig,
5357
InvocationStatus,
@@ -300,6 +304,118 @@ export default class ComputeWorker {
300304
throw new Error(`Function "${fn.name}" (${functionName}) is not invocable`);
301305
}
302306

307+
// Determine dispatch mode: inline (in-process) vs HTTP
308+
const isInline = fn.runtime === 'inline' || getInlineImpl(functionName) !== null;
309+
310+
if (isInline) {
311+
await this.doWorkInline(job, fn, graphNode, payload);
312+
} else {
313+
await this.doWorkHttp(job, fn, graphNode, payload);
314+
}
315+
}
316+
317+
// ─── Inline dispatch (in-process, no HTTP) ──────────────────────────────
318+
319+
private async doWorkInline(
320+
job: ComputeJobRow,
321+
fn: PlatformFunctionDefinition,
322+
graphNode: boolean,
323+
payload: any
324+
): Promise<void> {
325+
const { task_identifier } = job;
326+
const functionName = fn.task_identifier;
327+
const databaseId = job.database_id || this.platformDatabaseId;
328+
const scope = job.entity_id ? 'org' : 'platform';
329+
330+
// Inline nodes use inputs directly; props come from the graph node definition
331+
const inputs = graphNode ? (payload.inputs as Record<string, unknown>) : (payload as Record<string, unknown>);
332+
333+
await this.setJobGUCs(job);
334+
335+
const { id: invocationId } = await this.tracker.create({
336+
task_identifier,
337+
payload,
338+
job_id: job.id,
339+
database_id: databaseId,
340+
actor_id: job.actor_id,
341+
scope,
342+
});
343+
344+
const reqStart = process.hrtime();
345+
try {
346+
const result = await executeInline(functionName, inputs, {});
347+
348+
const elapsed = process.hrtime(reqStart);
349+
const ms = Math.round((elapsed[0] * 1e9 + elapsed[1]) / 1e6);
350+
await this.tracker.complete(
351+
invocationId, ms, undefined,
352+
scope, scope === 'org' ? databaseId : undefined
353+
);
354+
355+
await this.computeLog.log({
356+
task_identifier,
357+
job_id: job.id,
358+
invocation_id: invocationId,
359+
database_id: databaseId,
360+
entity_id: job.entity_id,
361+
organization_id: job.organization_id,
362+
entity_type: job.entity_type,
363+
actor_id: job.actor_id,
364+
status: 'completed',
365+
duration_ms: ms,
366+
});
367+
368+
if (graphNode) {
369+
await this.completeGraphNode(
370+
payload.execution_id,
371+
payload.node_name,
372+
result
373+
);
374+
}
375+
} catch (err: any) {
376+
const elapsed = process.hrtime(reqStart);
377+
const ms = Math.round((elapsed[0] * 1e9 + elapsed[1]) / 1e6);
378+
await this.tracker.fail(
379+
invocationId, ms, err.message,
380+
scope, scope === 'org' ? databaseId : undefined
381+
);
382+
383+
await this.computeLog.log({
384+
task_identifier,
385+
job_id: job.id,
386+
invocation_id: invocationId,
387+
database_id: databaseId,
388+
entity_id: job.entity_id,
389+
organization_id: job.organization_id,
390+
entity_type: job.entity_type,
391+
actor_id: job.actor_id,
392+
status: 'failed',
393+
duration_ms: ms,
394+
error: err.message,
395+
});
396+
397+
if (graphNode) {
398+
try {
399+
await this.failGraphExecution(payload.execution_id, err.message);
400+
} catch (graphErr) {
401+
log.error('Failed to mark graph execution as failed', graphErr);
402+
}
403+
}
404+
throw err;
405+
}
406+
}
407+
408+
// ─── HTTP dispatch (external function service) ──────────────────────────
409+
410+
private async doWorkHttp(
411+
job: ComputeJobRow,
412+
fn: PlatformFunctionDefinition,
413+
graphNode: boolean,
414+
payload: any
415+
): Promise<void> {
416+
const { task_identifier } = job;
417+
const functionName = fn.task_identifier;
418+
303419
const url = this.resolveUrl(fn.service_url, functionName);
304420
if (!url) {
305421
throw new Error(

0 commit comments

Comments
 (0)