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