Skip to content

Commit 688e9df

Browse files
os-zhuangclaude
andauthored
feat(runtime): extract /automation dispatcher domain body — ADR-0076 D11 step ③ PR-6 (#2462) (#3542)
The automation service bridge (~260 lines) moves to domains/automation.ts. Cleanest extraction of the series: zero new DomainHandlerDeps growth (getService/resolveService/success/error cover it). Route-order subtlety preserved verbatim — /actions, /connectors and /_status keep their guard positions before the /:name→getFlow catch-all (a flow literally named "actions" must not shadow them; a new seam test locks the guard position). NOTE: /actions (handleActions) is NOT in this cut — it is deep-coupled to env-resolution state (this.kernel / kernelResolver / resolveDefaultProject + 4 private helpers) and goes with the /data /meta deep-coupling batch. Verified: seam suite 37 tests, runtime 642 green, http-conformance 41 green, dependent closure builds with DTS (--force). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 4f1f843 commit 688e9df

4 files changed

Lines changed: 333 additions & 260 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@objectstack/runtime": minor
3+
---
4+
5+
feat(runtime): extract the /automation dispatcher domain body — ADR-0076 D11 step ③, PR-6 (#2462)
6+
7+
The automation bridge (flow CRUD, trigger/execute, runs history,
8+
pause/resume — the ADR-0018/0019/0022 surfaces, ~260 lines) moves to
9+
`domains/automation.ts` with zero new deps-contract growth. The route-order
10+
subtlety is preserved verbatim: `/actions`, `/connectors` and `/_status`
11+
keep their guard positions before the `/:name → getFlow` catch-all. Zero
12+
behavior change — http-conformance (41) plus 3 new seam tests.

packages/runtime/src/domain-handler-registry.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,3 +407,37 @@ describe('HttpDispatcher extracted domains (PR-5: packages)', () => {
407407
expect(result.response?.status).toBe(400);
408408
});
409409
});
410+
411+
// ---------------------------------------------------------------------------
412+
// PR-6 — automation extraction
413+
// ---------------------------------------------------------------------------
414+
415+
describe('HttpDispatcher extracted domains (PR-6: automation)', () => {
416+
it('GET /automation lists flows via the automation service', async () => {
417+
const automation = { listFlows: vi.fn().mockResolvedValue(['flow-a', 'flow-b']) };
418+
const result = await makeDispatcher({ automation }).dispatch('GET', '/automation', undefined, {}, {} as any);
419+
expect(result.response?.status).toBe(200);
420+
expect(result.response?.body?.data?.total).toBe(2);
421+
});
422+
423+
it('GET /automation/actions keeps its guard position before the /:name catch-all and applies filters', async () => {
424+
const automation = {
425+
listFlows: vi.fn(),
426+
getFlow: vi.fn(),
427+
getActionDescriptors: vi.fn().mockReturnValue([
428+
{ name: 'a1', source: 'builtin', paradigms: ['flow'] },
429+
{ name: 'a2', source: 'plugin', paradigms: ['workflow'] },
430+
]),
431+
};
432+
const result = await makeDispatcher({ automation }).dispatch('GET', '/automation/actions', undefined, { source: 'plugin' }, {} as any);
433+
expect(result.response?.status).toBe(200);
434+
expect(result.response?.body?.data?.actions).toHaveLength(1);
435+
// The /:name→getFlow catch-all must NOT have shadowed the guard route.
436+
expect(automation.getFlow).not.toHaveBeenCalled();
437+
});
438+
439+
it('falls through unhandled when no automation service is registered', async () => {
440+
const result = await makeDispatcher().dispatch('GET', '/automation', undefined, {}, {} as any);
441+
expect(result.response?.status ?? 404).not.toBe(200);
442+
});
443+
});
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `/automation` domain — extracted dispatcher body (ADR-0076 D11 step ③,
5+
* PR-6). Bridges to the `automation` service (flow CRUD, trigger/execute,
6+
* runs history, pause/resume — ADR-0018/0019/0022 surfaces). Route-order
7+
* subtlety preserved verbatim: `/actions`, `/connectors` and `/_status`
8+
* MUST precede the `/:name → getFlow` catch-all, or a flow literally named
9+
* "actions"/"connectors" would shadow them.
10+
*/
11+
12+
import { CoreServiceName } from '@objectstack/spec/system';
13+
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
14+
import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js';
15+
16+
export function createAutomationDomain(deps: DomainHandlerDeps): DomainRoute {
17+
return {
18+
prefix: '/automation',
19+
handler: (req, context) =>
20+
handleAutomationRequest(deps, req.path.substring(11), req.method, req.body, context, req.query),
21+
};
22+
}
23+
24+
/**
25+
* Handles Automation requests
26+
* path: sub-path after /automation/
27+
*
28+
* Routes:
29+
* GET / → listFlows
30+
* GET /actions → getActionDescriptors (ADR-0018; ?paradigm/?source/?category filters)
31+
* GET /connectors → getConnectorDescriptors (ADR-0022; ?type filter)
32+
* GET /:name → getFlow
33+
* POST / → createFlow (registerFlow)
34+
* PUT /:name → updateFlow
35+
* DELETE /:name → deleteFlow (unregisterFlow)
36+
* POST /:name/trigger → execute (legacy: trigger/:name also supported)
37+
* POST /:name/toggle → toggleFlow
38+
* GET /:name/runs → listRuns
39+
* GET /:name/runs/:runId → getRun
40+
* POST /:name/runs/:runId/resume → resume a paused run (screen input / ADR-0019)
41+
* GET /:name/runs/:runId/screen → the screen a paused run awaits
42+
*/
43+
export async function handleAutomationRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise<HttpDispatcherResult> {
44+
const automationService = await deps.getService(CoreServiceName.enum.automation);
45+
if (!automationService) return { handled: false };
46+
47+
const m = method.toUpperCase();
48+
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);
49+
50+
// Legacy: POST /automation/trigger/:name
51+
if (parts[0] === 'trigger' && parts[1] && m === 'POST') {
52+
const triggerName = parts[1];
53+
if (typeof automationService.trigger === 'function') {
54+
const result = await automationService.trigger(triggerName, body, { request: context.request });
55+
return { handled: true, response: deps.success(result) };
56+
}
57+
// Fallback to execute
58+
if (typeof automationService.execute === 'function') {
59+
const result = await automationService.execute(triggerName, body);
60+
return { handled: true, response: deps.success(result) };
61+
}
62+
}
63+
64+
// GET / → listFlows
65+
if (parts.length === 0 && m === 'GET') {
66+
if (typeof automationService.listFlows === 'function') {
67+
const names = await automationService.listFlows();
68+
return { handled: true, response: deps.success({ flows: names, total: names.length, hasMore: false }) };
69+
}
70+
}
71+
72+
// POST / → createFlow
73+
if (parts.length === 0 && m === 'POST') {
74+
if (typeof automationService.registerFlow === 'function') {
75+
automationService.registerFlow(body?.name, body);
76+
return { handled: true, response: deps.success(body) };
77+
}
78+
}
79+
80+
// GET /actions → list registered action descriptors (ADR-0018).
81+
// MUST precede the `/:name → getFlow` catch-all below, otherwise a
82+
// flow lookup for a flow literally named "actions" would shadow it.
83+
// Backs the designer palette + flow validation; the registry is open
84+
// and marketplace-extensible (built-in + plugin-contributed actions).
85+
if (parts[0] === 'actions' && parts.length === 1 && m === 'GET') {
86+
if (typeof automationService.getActionDescriptors === 'function') {
87+
let actions = automationService.getActionDescriptors() ?? [];
88+
// Optional filters mirror descriptor fields.
89+
if (query?.paradigm) {
90+
actions = actions.filter((a: any) => Array.isArray(a?.paradigms) && a.paradigms.includes(query.paradigm));
91+
}
92+
if (query?.source) {
93+
actions = actions.filter((a: any) => a?.source === query.source);
94+
}
95+
if (query?.category) {
96+
actions = actions.filter((a: any) => a?.category === query.category);
97+
}
98+
return { handled: true, response: deps.success({ actions, total: actions.length }) };
99+
}
100+
// Service present but does not implement the optional method:
101+
// report an empty (but valid) registry rather than a 404.
102+
return { handled: true, response: deps.success({ actions: [], total: 0 }) };
103+
}
104+
105+
// GET /connectors → list registered connector descriptors (ADR-0022).
106+
// Like /actions, MUST precede the `/:name → getFlow` catch-all so a flow
107+
// named "connectors" cannot shadow it. Backs the designer's
108+
// `connector_action` connector/action/input pickers; the registry is
109+
// empty in baseline and populated by connector plugins (e.g.
110+
// @objectstack/connector-rest, @objectstack/connector-slack).
111+
if (parts[0] === 'connectors' && parts.length === 1 && m === 'GET') {
112+
if (typeof automationService.getConnectorDescriptors === 'function') {
113+
let connectors = automationService.getConnectorDescriptors() ?? [];
114+
// Optional filter mirrors the descriptor's connector type.
115+
if (query?.type) {
116+
connectors = connectors.filter((c: any) => c?.type === query.type);
117+
}
118+
return { handled: true, response: deps.success({ connectors, total: connectors.length }) };
119+
}
120+
// Service present but does not implement the optional method:
121+
// report an empty (but valid) registry rather than a 404.
122+
return { handled: true, response: deps.success({ connectors: [], total: 0 }) };
123+
}
124+
125+
// GET /_status → runtime enable/bound state for every flow (backs the
126+
// Studio's Automations status badges: persisted `status` is metadata, but
127+
// whether a flow is actually enabled + bound to its trigger is engine
128+
// state). Underscore-prefixed so no flow name can shadow it; MUST precede
129+
// the `/:name → getFlow` catch-all.
130+
if (parts[0] === '_status' && parts.length === 1 && m === 'GET') {
131+
const svc = automationService as { getFlowRuntimeStates?: () => Array<{ name: string; enabled: boolean; bound: boolean }> };
132+
if (typeof svc.getFlowRuntimeStates === 'function') {
133+
const flows = svc.getFlowRuntimeStates();
134+
return { handled: true, response: deps.success({ flows, total: flows.length }) };
135+
}
136+
// Service present but older / does not implement the method.
137+
return { handled: true, response: deps.success({ flows: [], total: 0 }) };
138+
}
139+
140+
// Routes with :name
141+
if (parts.length >= 1) {
142+
const name = parts[0];
143+
144+
// POST /:name/trigger → execute
145+
if (parts[1] === 'trigger' && m === 'POST') {
146+
if (typeof automationService.execute === 'function') {
147+
const ctxBody = body && typeof body === 'object' ? body : {};
148+
// Translate UI/SDK request shape `{recordId, objectName, params}`
149+
// into the canonical AutomationContext shape expected by the engine.
150+
// Key transformations:
151+
// - `recordId` is exposed in `params.recordId` AND aliased to
152+
// `<objectName>Id` (camelCase) so flow variables like `leadId`,
153+
// `caseId`, `opportunityId` resolve from a single REST contract.
154+
// - `objectName` is mapped to the canonical `object` field.
155+
// - The user identity from the auth context (if any) is forwarded
156+
// as `userId` so node executors / template interpolation can
157+
// expand `{$User.Id}`.
158+
const recordId = ctxBody.recordId;
159+
const objectName = ctxBody.objectName ?? ctxBody.object;
160+
const baseParams = (ctxBody.params && typeof ctxBody.params === 'object') ? { ...ctxBody.params } : {};
161+
// Back-compat: when callers POST a flat body (no `params` wrapper),
162+
// forward unknown top-level keys as flow params so the original
163+
// `{ foo: 'bar' }` payload is not silently dropped.
164+
if (!ctxBody.params) {
165+
const reserved = new Set(['recordId', 'objectName', 'object', 'event', 'params']);
166+
for (const [k, v] of Object.entries(ctxBody)) {
167+
if (reserved.has(k)) continue;
168+
if (baseParams[k] === undefined) baseParams[k] = v;
169+
}
170+
}
171+
if (recordId !== undefined && baseParams.recordId === undefined) {
172+
baseParams.recordId = recordId;
173+
}
174+
if (recordId !== undefined && objectName) {
175+
const alias = `${String(objectName).replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase())}Id`;
176+
if (baseParams[alias] === undefined) baseParams[alias] = recordId;
177+
}
178+
const automationContext: any = {
179+
params: baseParams,
180+
object: objectName,
181+
event: ctxBody.event ?? 'manual',
182+
};
183+
// Forward the FULLY-RESOLVED caller identity (not just the
184+
// user id) so a `runAs:'user'` flow enforces RLS exactly as the
185+
// triggering user — their roles/tenant, not a member fallback
186+
// (#1888). The engine elevates to a system principal only when
187+
// the flow itself declares `runAs:'system'`.
188+
const ec = (context as any)?.executionContext;
189+
const userIdFromAuth = (context as any)?.user?.id ?? (context as any)?.userId ?? ec?.userId;
190+
if (userIdFromAuth) automationContext.userId = userIdFromAuth;
191+
if (Array.isArray(ec?.positions) && ec.positions.length) automationContext.positions = ec.positions;
192+
if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions;
193+
if (ec?.tenantId) automationContext.tenantId = ec.tenantId;
194+
const result = await automationService.execute(name, automationContext);
195+
return { handled: true, response: deps.success(result) };
196+
}
197+
}
198+
199+
// POST /:name/toggle → toggleFlow
200+
if (parts[1] === 'toggle' && m === 'POST') {
201+
if (typeof automationService.toggleFlow === 'function') {
202+
await automationService.toggleFlow(name, body?.enabled ?? true);
203+
return { handled: true, response: deps.success({ name, enabled: body?.enabled ?? true }) };
204+
}
205+
}
206+
207+
// POST /:name/runs/:runId/resume → resume a paused run (screen-flow
208+
// runtime / ADR-0019). Body `{ inputs }` = a screen node's collected
209+
// values, applied as bare flow variables; `output`/`branchLabel` also
210+
// forwarded for approval-style resumes. Returns the next paused
211+
// `{ screen }` (multi-screen) or the completed result.
212+
if (parts[1] === 'runs' && parts[2] && parts[3] === 'resume' && m === 'POST') {
213+
if (typeof automationService.resume === 'function') {
214+
const b = (body && typeof body === 'object') ? body : {};
215+
const inputs = (b.inputs ?? b.variables);
216+
const signal: any = {};
217+
if (inputs && typeof inputs === 'object') signal.variables = inputs;
218+
if (b.output && typeof b.output === 'object') signal.output = b.output;
219+
if (typeof b.branchLabel === 'string') signal.branchLabel = b.branchLabel;
220+
const result = await automationService.resume(parts[2], signal);
221+
return { handled: true, response: deps.success(result) };
222+
}
223+
return { handled: true, response: deps.error('Resume not supported', 501) };
224+
}
225+
226+
// GET /:name/runs/:runId/screen → the screen a paused run awaits
227+
// (refresh-safe re-fetch for the UI flow-runner).
228+
if (parts[1] === 'runs' && parts[2] && parts[3] === 'screen' && m === 'GET') {
229+
if (typeof automationService.getSuspendedScreen === 'function') {
230+
const screen = automationService.getSuspendedScreen(parts[2]);
231+
if (!screen) return { handled: true, response: deps.error('No pending screen for run', 404) };
232+
return { handled: true, response: deps.success({ runId: parts[2], screen }) };
233+
}
234+
return { handled: true, response: deps.error('Screen lookup not supported', 501) };
235+
}
236+
237+
// GET /:name/runs/:runId → getRun
238+
if (parts[1] === 'runs' && parts[2] && !parts[3] && m === 'GET') {
239+
if (typeof automationService.getRun === 'function') {
240+
const run = await automationService.getRun(parts[2]);
241+
if (!run) return { handled: true, response: deps.error('Execution not found', 404) };
242+
return { handled: true, response: deps.success(run) };
243+
}
244+
}
245+
246+
// GET /:name/runs → listRuns
247+
if (parts[1] === 'runs' && !parts[2] && m === 'GET') {
248+
if (typeof automationService.listRuns === 'function') {
249+
const options = query ? { limit: query.limit ? Number(query.limit) : undefined, cursor: query.cursor } : undefined;
250+
const runs = await automationService.listRuns(name, options);
251+
return { handled: true, response: deps.success({ runs, hasMore: false }) };
252+
}
253+
}
254+
255+
// GET /:name → getFlow (no sub-path)
256+
if (parts.length === 1 && m === 'GET') {
257+
if (typeof automationService.getFlow === 'function') {
258+
const flow = await automationService.getFlow(name);
259+
if (!flow) return { handled: true, response: deps.error('Flow not found', 404) };
260+
return { handled: true, response: deps.success(flow) };
261+
}
262+
}
263+
264+
// PUT /:name → updateFlow
265+
if (parts.length === 1 && m === 'PUT') {
266+
if (typeof automationService.registerFlow === 'function') {
267+
automationService.registerFlow(name, body?.definition ?? body);
268+
return { handled: true, response: deps.success(body?.definition ?? body) };
269+
}
270+
}
271+
272+
// DELETE /:name → deleteFlow
273+
if (parts.length === 1 && m === 'DELETE') {
274+
if (typeof automationService.unregisterFlow === 'function') {
275+
automationService.unregisterFlow(name);
276+
return { handled: true, response: deps.success({ name, deleted: true }) };
277+
}
278+
}
279+
}
280+
281+
return { handled: false };
282+
}

0 commit comments

Comments
 (0)