Skip to content

Commit cd84de3

Browse files
feat(automation): connector discovery endpoint + worked connector_action example (ADR-0022) (#1419)
Tier 1 of the connector roadmap: make the registered-connector set discoverable so the flow designer can populate the connector_action node's connector / action / input pickers. - engine: add getConnectorDescriptors() returning a designer-facing view per registered connector (identity + actions' input/output JSON Schema); handlers are runtime code, omitted from the metadata. New ConnectorDescriptor / ConnectorActionDescriptor types exported. - runtime: add GET /api/v1/automation/connectors (ADR-0022), mirroring GET /actions — ordered before the /:name flow catch-all, supports a ?type filter, and reports an empty registry rather than 404 when the service lacks the optional method. - examples/app-showcase: add TaskCompletedSlackFlow, the worked connector_action example dispatching to the slack connector's chat.postMessage (the 'raw API call' path from ADR-0022). Tests: engine descriptor unit tests + dispatcher /connectors route tests (filter, catch-all shadowing, empty-registry fallback). All service-automation (89) and runtime (329) tests green; showcase typechecks. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 4d56bfd commit cd84de3

6 files changed

Lines changed: 217 additions & 1 deletion

File tree

examples/app-showcase/src/flows/index.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,63 @@ export const BudgetApprovalFlow = defineFlow({
154154
],
155155
});
156156

157-
export const allFlows = [TaskCompletedFlow, ReassignWizardFlow, BudgetApprovalFlow];
157+
/**
158+
* Task Completed → Post to Slack — the worked `connector_action` example
159+
* (ADR-0018 §Addendum, ADR-0022).
160+
*
161+
* Unlike {@link TaskCompletedFlow}, which hand-waves notification via a `script`
162+
* node, this flow takes the "raw API call" path: a baseline `connector_action`
163+
* node dispatches to the `slack` connector's `chat.postMessage` action. The
164+
* `connector_action` node type is built into every automation engine; the
165+
* `slack` connector itself is contributed at runtime by the
166+
* `@objectstack/connector-slack` plugin (static bot-token auth). Load that
167+
* plugin in your stack and the node resolves; omit it and the step fails with a
168+
* clear "connector slack not registered" error rather than silently no-op'ing.
169+
*
170+
* The connector → action → input pickers the designer shows for this node are
171+
* fed by `GET /api/v1/automation/connectors`, which enumerates the live
172+
* registry (see `getConnectorDescriptors`).
173+
*/
174+
export const TaskCompletedSlackFlow = defineFlow({
175+
name: 'showcase_task_completed_slack',
176+
label: 'Post to Slack on Task Completed',
177+
description: 'Posts to a Slack channel via the slack connector when a task is marked Done.',
178+
type: 'autolaunched',
179+
nodes: [
180+
{
181+
id: 'start',
182+
type: 'start',
183+
label: 'On Task Update',
184+
config: {
185+
objectName: 'showcase_task',
186+
triggerType: 'record-after-update',
187+
condition: 'status == "done" && previous.status != "done"',
188+
},
189+
},
190+
{
191+
id: 'post_to_slack',
192+
type: 'connector_action',
193+
label: 'Post to #wins',
194+
connectorConfig: {
195+
connectorId: 'slack',
196+
actionId: 'chat.postMessage',
197+
input: {
198+
channel: 'C0WINS000',
199+
text: '✅ Task done: {record.title}',
200+
},
201+
},
202+
},
203+
{ id: 'end', type: 'end', label: 'End' },
204+
],
205+
edges: [
206+
{ id: 'e1', source: 'start', target: 'post_to_slack' },
207+
{ id: 'e2', source: 'post_to_slack', target: 'end' },
208+
],
209+
});
210+
211+
export const allFlows = [
212+
TaskCompletedFlow,
213+
ReassignWizardFlow,
214+
BudgetApprovalFlow,
215+
TaskCompletedSlackFlow,
216+
];

packages/runtime/src/http-dispatcher.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ describe('HttpDispatcher', () => {
150150
{ type: 'http_request', name: 'HTTP Request', category: 'io', paradigms: ['flow', 'workflow_rule'], source: 'builtin' },
151151
{ type: 'send_sms', name: 'Send SMS', category: 'io', paradigms: ['flow'], source: 'plugin' },
152152
]),
153+
getConnectorDescriptors: vi.fn().mockReturnValue([
154+
{ name: 'rest', label: 'REST', type: 'api', actions: [{ key: 'request', label: 'Request' }] },
155+
{ name: 'slack', label: 'Slack', type: 'api', actions: [{ key: 'chat.postMessage', label: 'Post Message' }] },
156+
{ name: 'pg', label: 'Postgres', type: 'database', actions: [] },
157+
]),
153158
};
154159

155160
// Set up kernel services to include automation
@@ -278,6 +283,40 @@ describe('HttpDispatcher', () => {
278283
expect(result.response?.body?.data?.actions).toEqual([]);
279284
expect(result.response?.body?.data?.total).toBe(0);
280285
});
286+
287+
// ── GET /connectors — connector descriptor registry (ADR-0022) ────
288+
it('should list connector descriptors via GET /connectors', async () => {
289+
const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} });
290+
expect(result.handled).toBe(true);
291+
expect(mockAutomationService.getConnectorDescriptors).toHaveBeenCalled();
292+
expect(result.response?.body?.data?.total).toBe(3);
293+
expect(result.response?.body?.data?.connectors.map((c: any) => c.name)).toEqual(
294+
['rest', 'slack', 'pg'],
295+
);
296+
});
297+
298+
it('must NOT let GET /connectors be shadowed by the /:name flow lookup', async () => {
299+
const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} });
300+
expect(result.handled).toBe(true);
301+
// The connector registry is returned, NOT a getFlow('connectors') result.
302+
expect(mockAutomationService.getFlow).not.toHaveBeenCalled();
303+
expect(result.response?.body?.data?.connectors).toBeDefined();
304+
});
305+
306+
it('should filter GET /connectors by ?type', async () => {
307+
const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} }, { type: 'database' });
308+
expect(result.handled).toBe(true);
309+
expect(result.response?.body?.data?.total).toBe(1);
310+
expect(result.response?.body?.data?.connectors[0].name).toBe('pg');
311+
});
312+
313+
it('should return an empty registry when the service lacks getConnectorDescriptors', async () => {
314+
delete mockAutomationService.getConnectorDescriptors;
315+
const result = await dispatcher.handleAutomation('connectors', 'GET', {}, { request: {} });
316+
expect(result.handled).toBe(true);
317+
expect(result.response?.body?.data?.connectors).toEqual([]);
318+
expect(result.response?.body?.data?.total).toBe(0);
319+
});
281320
});
282321

283322
// ═══════════════════════════════════════════════════════════════

packages/runtime/src/http-dispatcher.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1668,6 +1668,7 @@ export class HttpDispatcher {
16681668
* Routes:
16691669
* GET / → listFlows
16701670
* GET /actions → getActionDescriptors (ADR-0018; ?paradigm/?source/?category filters)
1671+
* GET /connectors → getConnectorDescriptors (ADR-0022; ?type filter)
16711672
* GET /:name → getFlow
16721673
* POST / → createFlow (registerFlow)
16731674
* PUT /:name → updateFlow
@@ -1739,6 +1740,26 @@ export class HttpDispatcher {
17391740
return { handled: true, response: this.success({ actions: [], total: 0 }) };
17401741
}
17411742

1743+
// GET /connectors → list registered connector descriptors (ADR-0022).
1744+
// Like /actions, MUST precede the `/:name → getFlow` catch-all so a flow
1745+
// named "connectors" cannot shadow it. Backs the designer's
1746+
// `connector_action` connector/action/input pickers; the registry is
1747+
// empty in baseline and populated by connector plugins (e.g.
1748+
// @objectstack/connector-rest, @objectstack/connector-slack).
1749+
if (parts[0] === 'connectors' && parts.length === 1 && m === 'GET') {
1750+
if (typeof automationService.getConnectorDescriptors === 'function') {
1751+
let connectors = automationService.getConnectorDescriptors() ?? [];
1752+
// Optional filter mirrors the descriptor's connector type.
1753+
if (query?.type) {
1754+
connectors = connectors.filter((c: any) => c?.type === query.type);
1755+
}
1756+
return { handled: true, response: this.success({ connectors, total: connectors.length }) };
1757+
}
1758+
// Service present but does not implement the optional method:
1759+
// report an empty (but valid) registry rather than a 404.
1760+
return { handled: true, response: this.success({ connectors: [], total: 0 }) };
1761+
}
1762+
17421763
// Routes with :name
17431764
if (parts.length >= 1) {
17441765
const name = parts[0];

packages/services/service-automation/src/builtin/connector-nodes.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,4 +176,47 @@ describe('AutomationEngine connector registry', () => {
176176
expect(engine.getRegisteredConnectors()).not.toContain('fake');
177177
expect(engine.resolveConnectorAction('fake', 'echo')).toBeUndefined();
178178
});
179+
180+
it('exposes a designer-facing descriptor per connector, omitting handlers (ADR-0022)', () => {
181+
engine.registerConnector(
182+
{
183+
name: 'fake',
184+
label: 'Fake Connector',
185+
type: 'api',
186+
icon: 'plug',
187+
authentication: { type: 'none' },
188+
actions: [
189+
{
190+
key: 'echo',
191+
label: 'Echo',
192+
description: 'Echo the input back',
193+
inputSchema: { message: { type: 'string' } },
194+
},
195+
],
196+
} as Connector,
197+
{ async echo() { return {}; } },
198+
);
199+
200+
const descriptors = engine.getConnectorDescriptors();
201+
expect(descriptors).toHaveLength(1);
202+
const fake = descriptors[0];
203+
expect(fake.name).toBe('fake');
204+
expect(fake.label).toBe('Fake Connector');
205+
expect(fake.type).toBe('api');
206+
expect(fake.icon).toBe('plug');
207+
expect(fake.actions).toHaveLength(1);
208+
expect(fake.actions[0]).toEqual({
209+
key: 'echo',
210+
label: 'Echo',
211+
description: 'Echo the input back',
212+
inputSchema: { message: { type: 'string' } },
213+
outputSchema: undefined,
214+
});
215+
// Handlers are runtime code, never leaked into the metadata view.
216+
expect((fake as any).handlers).toBeUndefined();
217+
});
218+
219+
it('returns an empty descriptor list when no connectors are registered', () => {
220+
expect(engine.getConnectorDescriptors()).toEqual([]);
221+
});
179222
});

packages/services/service-automation/src/engine.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,34 @@ export interface RegisteredConnector {
109109
readonly handlers: Record<string, ConnectorActionHandler>;
110110
}
111111

112+
/**
113+
* A designer-facing view of one connector action — identity + its JSON-Schema
114+
* input/output. The runtime handler is intentionally omitted; this is metadata.
115+
*/
116+
export interface ConnectorActionDescriptor {
117+
readonly key: string;
118+
readonly label: string;
119+
readonly description?: string;
120+
readonly inputSchema?: Record<string, unknown>;
121+
readonly outputSchema?: Record<string, unknown>;
122+
}
123+
124+
/**
125+
* A designer-facing descriptor for a registered connector: its identity plus
126+
* the actions it exposes. Served by `GET /api/v1/automation/connectors` so the
127+
* flow designer can populate the `connector_action` node's connector → action
128+
* → input pickers (ADR-0018 §Addendum, ADR-0022). Mirrors `ActionDescriptor`'s
129+
* role for node types, but for the connector registry.
130+
*/
131+
export interface ConnectorDescriptor {
132+
readonly name: string;
133+
readonly label: string;
134+
readonly type: string;
135+
readonly description?: string;
136+
readonly icon?: string;
137+
readonly actions: ConnectorActionDescriptor[];
138+
}
139+
112140
// ─── Core Automation Engine ─────────────────────────────────────────
113141

114142
/**
@@ -301,6 +329,30 @@ export class AutomationEngine implements IAutomationService {
301329
return [...this.connectors.keys()];
302330
}
303331

332+
/**
333+
* Get a designer-facing descriptor for every registered connector — its
334+
* identity plus the actions it exposes (input/output JSON Schema). Backs
335+
* `GET /api/v1/automation/connectors` so the designer can fill the
336+
* `connector_action` node's connector / action / input pickers (ADR-0022).
337+
* Handlers are omitted — they are runtime code, not metadata.
338+
*/
339+
getConnectorDescriptors(): ConnectorDescriptor[] {
340+
return [...this.connectors.values()].map(({ def }) => ({
341+
name: def.name,
342+
label: def.label,
343+
type: def.type,
344+
description: def.description,
345+
icon: def.icon,
346+
actions: (def.actions ?? []).map((a) => ({
347+
key: a.key,
348+
label: a.label,
349+
description: a.description,
350+
inputSchema: a.inputSchema,
351+
outputSchema: a.outputSchema,
352+
})),
353+
}));
354+
}
355+
304356
/** Get all registered node types */
305357
getRegisteredNodeTypes(): string[] {
306358
return [...this.nodeExecutors.keys()];

packages/services/service-automation/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export type {
99
ConnectorActionHandler,
1010
ConnectorActionContext,
1111
RegisteredConnector,
12+
ConnectorDescriptor,
13+
ConnectorActionDescriptor,
1214
} from './engine.js';
1315

1416
// Kernel plugin — seeds all built-in nodes; this is the only plugin needed for

0 commit comments

Comments
 (0)