Skip to content

Commit 7692eb8

Browse files
feat(automation): connector_action as baseline generic dispatch + connector-rest (ADR-0018) (#1416)
Promote `connector_action` to a built-in baseline node — the generic-dispatch counterpart to `http_request`: where http_request calls any raw URL, connector_action invokes any registered connector's declared action. - engine: connector registry (registerConnector / unregisterConnector / resolveConnectorAction / getRegisteredConnectors) + ConnectorActionHandler / ConnectorActionContext / RegisteredConnector types. registerConnector validates via ConnectorSchema and asserts every declared action has a handler. - builtin/connector-nodes.ts: connector_action executor (source:'builtin', category:'io', all three paradigms), wired into installBuiltinNodes() — the core plugin now seeds 11 baseline node types. Missing connector fails the step (not flow registration) with a clear error. - packages/connectors/connector-rest (@objectstack/connector-rest): the reference concrete connector. createRestConnector + ConnectorRestPlugin, `request` action, static auth (none/api-key/basic/bearer), no OAuth2 refresh (enterprise tier). - New packages/connectors/ workspace category (alongside plugins/services/adapters). - ADR-0018 §Addendum: records the decision, resolves Open-question #1, supersedes M2's "connector_action dropped from baseline". Tests: service-automation 87/87, connector-rest 10/10 (incl. end-to-end kernel boot: both plugins -> connector_action flow -> REST handler). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
1 parent 9c34a54 commit 7692eb8

18 files changed

Lines changed: 1083 additions & 17 deletions

docs/adr/0018-unified-node-action-registry.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,42 @@ No fourth engine. Workflow Rules stays a **simplified authoring view** for busin
195195
1. Does `connector_action` (the one verb already present in all three paradigms) become the *general* extension action, with `http`/`notify` as well-known specializations — or stay peer-level? Leaning: keep peer-level; `connector_action` targets a registered connector, `http` is raw.
196196
2. Should `screen` / `user_task` (human-input nodes) carry their own descriptor category (`human`) that the runtime treats as always-`isAsync`? Likely yes.
197197
3. Where does the action registry live for **cross-environment** consistency — is it per-environment (a plugin enabled in env A but not B yields different palettes)? Tie to the package/environment model (ADR-0006).
198+
199+
---
200+
201+
## Addendum (2026-05-31): `connector_action` is baseline generic dispatch
202+
203+
> Status of this addendum: **implemented.** This re-scopes the §Migration M2 note and resolves §Open-questions #1. The baseline registry + `connector_action` executor ship in `service-automation`, with `@objectstack/connector-rest` as the first concrete connector plugin.
204+
205+
### Decision
206+
207+
`connector_action` is promoted to a **built-in (`source: 'builtin'`) baseline node**, the generic-dispatch counterpart to `http_request`:
208+
209+
- where `http_request` calls **any raw URL**, `connector_action` invokes **any registered connector's declared action**;
210+
- the engine ships the dispatch node **plus an initially-empty connector registry** (`registerConnector` / `resolveConnectorAction` / `getRegisteredConnectors`);
211+
- **concrete** connectors (`@objectstack/connector-rest`, `connector-slack`, `connector-salesforce`, …) remain **plugins** that populate the registry at runtime.
212+
213+
This is the **mechanism/policy split**: the *mechanism* (registry + dispatch node) is baseline; the *concrete integrations* (and their credentials/lifecycle) are not. It mirrors the ADR-0015 datasource split — federation contract is in the open framework, managed connection lifecycle lives outside it.
214+
215+
### Why this reverses M2's "connector_action dropped from baseline"
216+
217+
M2 dropped `connector_action` because it would need "a connector registry the platform doesn't ship." That is circular: the registry is the missing piece, and an **empty** registry is zero-dependency and zero-cost. The protocol already commits to the node — `connector_action` is in `FLOW_BUILTIN_NODE_TYPES` and `connectorConfig {connectorId, actionId, input}` is already a `FlowNode` field — but ships **no executor**, so any flow referencing it fails at execution. Shipping the empty registry + dispatch executor closes that spec/runtime gap without pulling any concrete integration into the core.
218+
219+
### Resolves Open-question #1
220+
221+
The leaning ("keep peer-level") is **overturned for the dispatch direction, kept for the verbs**: `connector_action` *does* become the general connector-extension action, while `http`/`notify` stay **peer-level raw verbs** (not specializations of it). `http_request` calls a URL with no registration; `connector_action` calls a registered, named capability. Both are baseline; neither is implemented in terms of the other.
222+
223+
### Graceful degradation
224+
225+
Because the registry starts empty, a flow that references a connector no plugin has registered **fails that step with a clear error** (`no handler for '<id>.<action>' — is the connector plugin registered?`) rather than failing to register the flow — the same fail-soft posture `http_request` takes on a bad URL.
226+
227+
### Out of scope (deliberately not baseline)
228+
229+
Managed credentials/secret vault, OAuth2 token refresh, multi-tenant connection lifecycle, and a connector marketplace are **not** part of this mechanism — they are the enterprise tier, on the ADR-0015 precedent. The open framework ships the contract + dispatch + an in-process registry only.
230+
231+
### Implementation checklist
232+
233+
- [x] `AutomationEngine`: connector registry (`registerConnector` / `unregisterConnector` / `resolveConnectorAction` / `getRegisteredConnectors`) + `ConnectorActionHandler` / `ConnectorActionContext` types.
234+
- [x] `builtin/connector-nodes.ts`: `connector_action` executor + descriptor (`category: 'io'`, `source: 'builtin'`, `paradigms: ['flow','workflow_rule','approval']`), wired into `installBuiltinNodes()`. The core plugin now seeds 11 baseline node types (was 10).
235+
- [x] First concrete plugin `@objectstack/connector-rest` (the reference connector) validating the registry — `request` action, static auth (`none`/`api-key`/`basic`/`bearer`), no OAuth2 refresh.
236+
- [x] Tests: baseline dispatch (fake connector) + REST plugin auth-header injection + end-to-end kernel boot (both plugins → `connector_action` flow → REST handler).
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
2+
"Business Source License" is a trademark of MariaDB Corporation Ab.
3+
4+
Parameters
5+
6+
Licensor: ObjectStack AI LLC
7+
Licensed Work: ObjectStack Runtime: the BSL-licensed packages
8+
of the ObjectStack monorepo as listed in LICENSING.md.
9+
Copyright (c) 2026 ObjectStack AI LLC.
10+
Additional Use Grant: You may make production use of the Licensed Work, provided
11+
Your use does not include offering the Licensed Work to third
12+
parties on a hosted or embedded basis in order to compete with
13+
ObjectStack AI LLC's paid version(s) of the Licensed Work. For purposes
14+
of this license:
15+
16+
A "competitive offering" is a Product that is offered to third
17+
parties on a paid basis, including through paid support
18+
arrangements, that significantly overlaps with the capabilities
19+
of ObjectStack AI LLC's paid version(s) of the Licensed Work. If Your
20+
Product is not a competitive offering when You first make it
21+
generally available, it will not become a competitive offering
22+
later due to ObjectStack AI LLC releasing a new version of the Licensed
23+
Work with additional capabilities. In addition, Products that
24+
are not provided on a paid basis are not competitive.
25+
26+
"Product" means software that is offered to end users to manage
27+
in their own environments or offered as a service on a hosted
28+
basis.
29+
30+
"Embedded" means including the source code or executable code
31+
from the Licensed Work in a competitive offering. "Embedded"
32+
also means packaging the competitive offering in such a way
33+
that the Licensed Work must be accessed or downloaded for the
34+
competitive offering to operate.
35+
36+
Hosting or using the Licensed Work(s) for internal purposes
37+
within an organization is not considered a competitive
38+
offering. ObjectStack AI LLC considers your organization to include all
39+
of your affiliates under common control.
40+
41+
For binding interpretive guidance on using ObjectStack AI LLC products
42+
under the Business Source License, please visit our FAQ.
43+
(see LICENSING.md in this repository)
44+
Change Date: Four years from the date the Licensed Work is published.
45+
Change License: Apache License, Version 2.0
46+
47+
For information about alternative licensing arrangements for the Licensed Work,
48+
please contact licensing@objectstack.dev.
49+
50+
Notice
51+
52+
Business Source License 1.1
53+
54+
Terms
55+
56+
The Licensor hereby grants you the right to copy, modify, create derivative
57+
works, redistribute, and make non-production use of the Licensed Work. The
58+
Licensor may make an Additional Use Grant, above, permitting limited production use.
59+
60+
Effective on the Change Date, or the fourth anniversary of the first publicly
61+
available distribution of a specific version of the Licensed Work under this
62+
License, whichever comes first, the Licensor hereby grants you rights under
63+
the terms of the Change License, and the rights granted in the paragraph
64+
above terminate.
65+
66+
If your use of the Licensed Work does not comply with the requirements
67+
currently in effect as described in this License, you must purchase a
68+
commercial license from the Licensor, its affiliated entities, or authorized
69+
resellers, or you must refrain from using the Licensed Work.
70+
71+
All copies of the original and modified Licensed Work, and derivative works
72+
of the Licensed Work, are subject to this License. This License applies
73+
separately for each version of the Licensed Work and the Change Date may vary
74+
for each version of the Licensed Work released by Licensor.
75+
76+
You must conspicuously display this License on each original or modified copy
77+
of the Licensed Work. If you receive the Licensed Work in original or
78+
modified form from a third party, the terms and conditions set forth in this
79+
License apply to your use of that work.
80+
81+
Any use of the Licensed Work in violation of this License will automatically
82+
terminate your rights under this License for the current and all other
83+
versions of the Licensed Work.
84+
85+
This License does not grant you any right in any trademark or logo of
86+
Licensor or its affiliates (provided that you may use a trademark or logo of
87+
Licensor as expressly required by this License).
88+
89+
TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
90+
AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
91+
EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
92+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
93+
TITLE.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
{
2+
"name": "@objectstack/connector-rest",
3+
"version": "7.3.0",
4+
"license": "Apache-2.0",
5+
"description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup --config ../../../tsup.config.ts",
17+
"test": "vitest run --passWithNoTests"
18+
},
19+
"dependencies": {
20+
"@objectstack/core": "workspace:*",
21+
"@objectstack/spec": "workspace:*"
22+
},
23+
"devDependencies": {
24+
"@objectstack/service-automation": "workspace:*",
25+
"@types/node": "^25.9.1",
26+
"typescript": "^6.0.3",
27+
"vitest": "^4.1.7"
28+
},
29+
"keywords": [
30+
"objectstack",
31+
"connector",
32+
"rest",
33+
"integration",
34+
"http"
35+
]
36+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { LiteKernel } from '@objectstack/core';
5+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
6+
import { ConnectorRestPlugin } from './connector-rest-plugin.js';
7+
8+
/** A fetch stub recording calls, returning a fixed JSON response. */
9+
function stubFetch() {
10+
const calls: Array<{ url: string; init: RequestInit }> = [];
11+
const impl = (async (url: string, init: RequestInit) => {
12+
calls.push({ url, init });
13+
return {
14+
status: 201,
15+
ok: true,
16+
headers: { get: (h: string) => (h.toLowerCase() === 'content-type' ? 'application/json' : null) },
17+
json: async () => ({ id: 'created-1' }),
18+
text: async () => '{"id":"created-1"}',
19+
};
20+
}) as unknown as typeof fetch;
21+
return { impl, calls };
22+
}
23+
24+
describe('ConnectorRestPlugin — end to end with the automation engine', () => {
25+
it('registers the REST connector so a connector_action flow dispatches to it', async () => {
26+
const { impl, calls } = stubFetch();
27+
28+
const kernel = new LiteKernel();
29+
kernel.use(new AutomationServicePlugin());
30+
kernel.use(
31+
new ConnectorRestPlugin({
32+
baseUrl: 'https://api.example.com',
33+
auth: { type: 'bearer', token: 'secret-token' },
34+
fetchImpl: impl,
35+
}),
36+
);
37+
await kernel.bootstrap();
38+
39+
const engine = kernel.getService<AutomationEngine>('automation');
40+
41+
// The baseline node and the plugin-contributed connector are both present.
42+
expect(engine.getRegisteredNodeTypes()).toContain('connector_action');
43+
expect(engine.getRegisteredConnectors()).toContain('rest');
44+
45+
engine.registerFlow('create_via_rest', {
46+
name: 'create_via_rest',
47+
label: 'Create via REST',
48+
type: 'autolaunched',
49+
variables: [{ name: 'call.body', type: 'json', isOutput: true }],
50+
nodes: [
51+
{ id: 'start', type: 'start', label: 'Start' },
52+
{
53+
id: 'call',
54+
type: 'connector_action',
55+
label: 'POST /items',
56+
connectorConfig: {
57+
connectorId: 'rest',
58+
actionId: 'request',
59+
input: { method: 'POST', path: '/items', body: { name: 'Widget' } },
60+
},
61+
},
62+
{ id: 'end', type: 'end', label: 'End' },
63+
],
64+
edges: [
65+
{ id: 'e1', source: 'start', target: 'call' },
66+
{ id: 'e2', source: 'call', target: 'end' },
67+
],
68+
});
69+
70+
const result = await engine.execute('create_via_rest');
71+
72+
expect(result.success).toBe(true);
73+
// The REST connector handled the dispatch: one fetch with auth + body.
74+
expect(calls).toHaveLength(1);
75+
expect(calls[0].url).toBe('https://api.example.com/items');
76+
expect(calls[0].init.method).toBe('POST');
77+
expect((calls[0].init.headers as Record<string, string>)['Authorization']).toBe('Bearer secret-token');
78+
// The action output propagated back into the flow.
79+
expect(result.output).toEqual({ 'call.body': { id: 'created-1' } });
80+
81+
await kernel.shutdown();
82+
});
83+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Plugin, PluginContext } from '@objectstack/core';
4+
import type { Connector } from '@objectstack/spec/integration';
5+
import { createRestConnector, type RestConnectorOptions } from './rest-connector.js';
6+
7+
/**
8+
* Minimal surface of the automation engine this plugin depends on — the
9+
* connector registry from ADR-0018 §Addendum. Kept structural so the plugin
10+
* needs no runtime dependency on `@objectstack/service-automation`.
11+
*/
12+
export interface ConnectorRegistrySurface {
13+
registerConnector(
14+
def: Connector,
15+
handlers: Record<
16+
string,
17+
(input: Record<string, unknown>, ctx: unknown) => Promise<Record<string, unknown>>
18+
>,
19+
): void;
20+
unregisterConnector(name: string): void;
21+
}
22+
23+
export interface ConnectorRestPluginOptions extends RestConnectorOptions {}
24+
25+
/**
26+
* ConnectorRestPlugin — registers a generic REST connector on the automation
27+
* engine. This is the **reference concrete connector** (ADR-0018 §Addendum):
28+
* the dispatch node + registry are baseline; a connector like this one is a
29+
* plugin that populates the registry.
30+
*
31+
* If no automation engine is present the plugin logs and skips — the connector
32+
* has nowhere to register, which is not an error.
33+
*/
34+
export class ConnectorRestPlugin implements Plugin {
35+
name = 'com.objectstack.connector.rest';
36+
version = '1.0.0';
37+
type = 'standard' as const;
38+
// Ensure the automation engine (and its connector registry) is started first.
39+
dependencies = ['com.objectstack.service-automation'];
40+
41+
private readonly options: ConnectorRestPluginOptions;
42+
private connectorName?: string;
43+
private automation?: ConnectorRegistrySurface;
44+
45+
constructor(options: ConnectorRestPluginOptions) {
46+
this.options = options;
47+
}
48+
49+
async init(_ctx: PluginContext): Promise<void> {
50+
// No services to register; the connector is registered in start() once
51+
// the automation engine is available.
52+
}
53+
54+
async start(ctx: PluginContext): Promise<void> {
55+
let automation: ConnectorRegistrySurface | undefined;
56+
try {
57+
automation = ctx.getService<ConnectorRegistrySurface>('automation');
58+
} catch {
59+
automation = undefined;
60+
}
61+
62+
if (!automation || typeof automation.registerConnector !== 'function') {
63+
ctx.logger.info('ConnectorRestPlugin: no automation engine — REST connector not registered');
64+
return;
65+
}
66+
67+
const { def, handlers } = createRestConnector(this.options);
68+
automation.registerConnector(def, handlers);
69+
this.automation = automation;
70+
this.connectorName = def.name;
71+
ctx.logger.info(`ConnectorRestPlugin: REST connector '${def.name}' registered`);
72+
}
73+
74+
async stop(_ctx: PluginContext): Promise<void> {
75+
if (this.automation && this.connectorName) {
76+
try { this.automation.unregisterConnector(this.connectorName); } catch { /* ignore */ }
77+
}
78+
}
79+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @objectstack/connector-rest
5+
*
6+
* Generic REST connector — the reference *concrete* connector (ADR-0018
7+
* §Addendum). The baseline automation engine ships the `connector_action`
8+
* dispatch node + an empty connector registry; this plugin populates the
9+
* registry with a `rest` connector exposing a `request` action.
10+
*
11+
* Static auth only (`none` / `api-key` / `basic` / `bearer`); OAuth2 refresh,
12+
* credential vaulting, and multi-tenant lifecycle are the enterprise tier.
13+
*/
14+
15+
export {
16+
createRestConnector,
17+
type RestConnectorOptions,
18+
type RestConnectorBundle,
19+
type RestRequestInput,
20+
type RestAuth,
21+
} from './rest-connector.js';
22+
export {
23+
ConnectorRestPlugin,
24+
type ConnectorRestPluginOptions,
25+
type ConnectorRegistrySurface,
26+
} from './connector-rest-plugin.js';

0 commit comments

Comments
 (0)