diff --git a/.changeset/runtime-automation-extraction.md b/.changeset/runtime-automation-extraction.md new file mode 100644 index 0000000000..c26a8ef0f0 --- /dev/null +++ b/.changeset/runtime-automation-extraction.md @@ -0,0 +1,12 @@ +--- +"@objectstack/runtime": minor +--- + +feat(runtime): extract the /automation dispatcher domain body — ADR-0076 D11 step ③, PR-6 (#2462) + +The automation bridge (flow CRUD, trigger/execute, runs history, +pause/resume — the ADR-0018/0019/0022 surfaces, ~260 lines) moves to +`domains/automation.ts` with zero new deps-contract growth. The route-order +subtlety is preserved verbatim: `/actions`, `/connectors` and `/_status` +keep their guard positions before the `/:name → getFlow` catch-all. Zero +behavior change — http-conformance (41) plus 3 new seam tests. diff --git a/packages/runtime/src/domain-handler-registry.test.ts b/packages/runtime/src/domain-handler-registry.test.ts index 6617fd8636..282c59c9c6 100644 --- a/packages/runtime/src/domain-handler-registry.test.ts +++ b/packages/runtime/src/domain-handler-registry.test.ts @@ -407,3 +407,37 @@ describe('HttpDispatcher extracted domains (PR-5: packages)', () => { expect(result.response?.status).toBe(400); }); }); + +// --------------------------------------------------------------------------- +// PR-6 — automation extraction +// --------------------------------------------------------------------------- + +describe('HttpDispatcher extracted domains (PR-6: automation)', () => { + it('GET /automation lists flows via the automation service', async () => { + const automation = { listFlows: vi.fn().mockResolvedValue(['flow-a', 'flow-b']) }; + const result = await makeDispatcher({ automation }).dispatch('GET', '/automation', undefined, {}, {} as any); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.total).toBe(2); + }); + + it('GET /automation/actions keeps its guard position before the /:name catch-all and applies filters', async () => { + const automation = { + listFlows: vi.fn(), + getFlow: vi.fn(), + getActionDescriptors: vi.fn().mockReturnValue([ + { name: 'a1', source: 'builtin', paradigms: ['flow'] }, + { name: 'a2', source: 'plugin', paradigms: ['workflow'] }, + ]), + }; + const result = await makeDispatcher({ automation }).dispatch('GET', '/automation/actions', undefined, { source: 'plugin' }, {} as any); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.actions).toHaveLength(1); + // The /:name→getFlow catch-all must NOT have shadowed the guard route. + expect(automation.getFlow).not.toHaveBeenCalled(); + }); + + it('falls through unhandled when no automation service is registered', async () => { + const result = await makeDispatcher().dispatch('GET', '/automation', undefined, {}, {} as any); + expect(result.response?.status ?? 404).not.toBe(200); + }); +}); diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts new file mode 100644 index 0000000000..9322530c07 --- /dev/null +++ b/packages/runtime/src/domains/automation.ts @@ -0,0 +1,282 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/automation` domain — extracted dispatcher body (ADR-0076 D11 step ③, + * PR-6). Bridges to the `automation` service (flow CRUD, trigger/execute, + * runs history, pause/resume — ADR-0018/0019/0022 surfaces). Route-order + * subtlety preserved verbatim: `/actions`, `/connectors` and `/_status` + * MUST precede the `/:name → getFlow` catch-all, or a flow literally named + * "actions"/"connectors" would shadow them. + */ + +import { CoreServiceName } from '@objectstack/spec/system'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; + +export function createAutomationDomain(deps: DomainHandlerDeps): DomainRoute { + return { + prefix: '/automation', + handler: (req, context) => + handleAutomationRequest(deps, req.path.substring(11), req.method, req.body, context, req.query), + }; +} + +/** + * Handles Automation requests + * path: sub-path after /automation/ + * + * Routes: + * GET / → listFlows + * GET /actions → getActionDescriptors (ADR-0018; ?paradigm/?source/?category filters) + * GET /connectors → getConnectorDescriptors (ADR-0022; ?type filter) + * GET /:name → getFlow + * POST / → createFlow (registerFlow) + * PUT /:name → updateFlow + * DELETE /:name → deleteFlow (unregisterFlow) + * POST /:name/trigger → execute (legacy: trigger/:name also supported) + * POST /:name/toggle → toggleFlow + * GET /:name/runs → listRuns + * GET /:name/runs/:runId → getRun + * POST /:name/runs/:runId/resume → resume a paused run (screen input / ADR-0019) + * GET /:name/runs/:runId/screen → the screen a paused run awaits + */ +export async function handleAutomationRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise { + const automationService = await deps.getService(CoreServiceName.enum.automation); + if (!automationService) return { handled: false }; + + const m = method.toUpperCase(); + const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); + + // Legacy: POST /automation/trigger/:name + if (parts[0] === 'trigger' && parts[1] && m === 'POST') { + const triggerName = parts[1]; + if (typeof automationService.trigger === 'function') { + const result = await automationService.trigger(triggerName, body, { request: context.request }); + return { handled: true, response: deps.success(result) }; + } + // Fallback to execute + if (typeof automationService.execute === 'function') { + const result = await automationService.execute(triggerName, body); + return { handled: true, response: deps.success(result) }; + } + } + + // GET / → listFlows + if (parts.length === 0 && m === 'GET') { + if (typeof automationService.listFlows === 'function') { + const names = await automationService.listFlows(); + return { handled: true, response: deps.success({ flows: names, total: names.length, hasMore: false }) }; + } + } + + // POST / → createFlow + if (parts.length === 0 && m === 'POST') { + if (typeof automationService.registerFlow === 'function') { + automationService.registerFlow(body?.name, body); + return { handled: true, response: deps.success(body) }; + } + } + + // GET /actions → list registered action descriptors (ADR-0018). + // MUST precede the `/:name → getFlow` catch-all below, otherwise a + // flow lookup for a flow literally named "actions" would shadow it. + // Backs the designer palette + flow validation; the registry is open + // and marketplace-extensible (built-in + plugin-contributed actions). + if (parts[0] === 'actions' && parts.length === 1 && m === 'GET') { + if (typeof automationService.getActionDescriptors === 'function') { + let actions = automationService.getActionDescriptors() ?? []; + // Optional filters mirror descriptor fields. + if (query?.paradigm) { + actions = actions.filter((a: any) => Array.isArray(a?.paradigms) && a.paradigms.includes(query.paradigm)); + } + if (query?.source) { + actions = actions.filter((a: any) => a?.source === query.source); + } + if (query?.category) { + actions = actions.filter((a: any) => a?.category === query.category); + } + return { handled: true, response: deps.success({ actions, total: actions.length }) }; + } + // Service present but does not implement the optional method: + // report an empty (but valid) registry rather than a 404. + return { handled: true, response: deps.success({ actions: [], total: 0 }) }; + } + + // GET /connectors → list registered connector descriptors (ADR-0022). + // Like /actions, MUST precede the `/:name → getFlow` catch-all so a flow + // named "connectors" cannot shadow it. Backs the designer's + // `connector_action` connector/action/input pickers; the registry is + // empty in baseline and populated by connector plugins (e.g. + // @objectstack/connector-rest, @objectstack/connector-slack). + if (parts[0] === 'connectors' && parts.length === 1 && m === 'GET') { + if (typeof automationService.getConnectorDescriptors === 'function') { + let connectors = automationService.getConnectorDescriptors() ?? []; + // Optional filter mirrors the descriptor's connector type. + if (query?.type) { + connectors = connectors.filter((c: any) => c?.type === query.type); + } + return { handled: true, response: deps.success({ connectors, total: connectors.length }) }; + } + // Service present but does not implement the optional method: + // report an empty (but valid) registry rather than a 404. + return { handled: true, response: deps.success({ connectors: [], total: 0 }) }; + } + + // GET /_status → runtime enable/bound state for every flow (backs the + // Studio's Automations status badges: persisted `status` is metadata, but + // whether a flow is actually enabled + bound to its trigger is engine + // state). Underscore-prefixed so no flow name can shadow it; MUST precede + // the `/:name → getFlow` catch-all. + if (parts[0] === '_status' && parts.length === 1 && m === 'GET') { + const svc = automationService as { getFlowRuntimeStates?: () => Array<{ name: string; enabled: boolean; bound: boolean }> }; + if (typeof svc.getFlowRuntimeStates === 'function') { + const flows = svc.getFlowRuntimeStates(); + return { handled: true, response: deps.success({ flows, total: flows.length }) }; + } + // Service present but older / does not implement the method. + return { handled: true, response: deps.success({ flows: [], total: 0 }) }; + } + + // Routes with :name + if (parts.length >= 1) { + const name = parts[0]; + + // POST /:name/trigger → execute + if (parts[1] === 'trigger' && m === 'POST') { + if (typeof automationService.execute === 'function') { + const ctxBody = body && typeof body === 'object' ? body : {}; + // Translate UI/SDK request shape `{recordId, objectName, params}` + // into the canonical AutomationContext shape expected by the engine. + // Key transformations: + // - `recordId` is exposed in `params.recordId` AND aliased to + // `Id` (camelCase) so flow variables like `leadId`, + // `caseId`, `opportunityId` resolve from a single REST contract. + // - `objectName` is mapped to the canonical `object` field. + // - The user identity from the auth context (if any) is forwarded + // as `userId` so node executors / template interpolation can + // expand `{$User.Id}`. + const recordId = ctxBody.recordId; + const objectName = ctxBody.objectName ?? ctxBody.object; + const baseParams = (ctxBody.params && typeof ctxBody.params === 'object') ? { ...ctxBody.params } : {}; + // Back-compat: when callers POST a flat body (no `params` wrapper), + // forward unknown top-level keys as flow params so the original + // `{ foo: 'bar' }` payload is not silently dropped. + if (!ctxBody.params) { + const reserved = new Set(['recordId', 'objectName', 'object', 'event', 'params']); + for (const [k, v] of Object.entries(ctxBody)) { + if (reserved.has(k)) continue; + if (baseParams[k] === undefined) baseParams[k] = v; + } + } + if (recordId !== undefined && baseParams.recordId === undefined) { + baseParams.recordId = recordId; + } + if (recordId !== undefined && objectName) { + const alias = `${String(objectName).replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase())}Id`; + if (baseParams[alias] === undefined) baseParams[alias] = recordId; + } + const automationContext: any = { + params: baseParams, + object: objectName, + event: ctxBody.event ?? 'manual', + }; + // Forward the FULLY-RESOLVED caller identity (not just the + // user id) so a `runAs:'user'` flow enforces RLS exactly as the + // triggering user — their roles/tenant, not a member fallback + // (#1888). The engine elevates to a system principal only when + // the flow itself declares `runAs:'system'`. + const ec = (context as any)?.executionContext; + const userIdFromAuth = (context as any)?.user?.id ?? (context as any)?.userId ?? ec?.userId; + if (userIdFromAuth) automationContext.userId = userIdFromAuth; + if (Array.isArray(ec?.positions) && ec.positions.length) automationContext.positions = ec.positions; + if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions; + if (ec?.tenantId) automationContext.tenantId = ec.tenantId; + const result = await automationService.execute(name, automationContext); + return { handled: true, response: deps.success(result) }; + } + } + + // POST /:name/toggle → toggleFlow + if (parts[1] === 'toggle' && m === 'POST') { + if (typeof automationService.toggleFlow === 'function') { + await automationService.toggleFlow(name, body?.enabled ?? true); + return { handled: true, response: deps.success({ name, enabled: body?.enabled ?? true }) }; + } + } + + // POST /:name/runs/:runId/resume → resume a paused run (screen-flow + // runtime / ADR-0019). Body `{ inputs }` = a screen node's collected + // values, applied as bare flow variables; `output`/`branchLabel` also + // forwarded for approval-style resumes. Returns the next paused + // `{ screen }` (multi-screen) or the completed result. + if (parts[1] === 'runs' && parts[2] && parts[3] === 'resume' && m === 'POST') { + if (typeof automationService.resume === 'function') { + const b = (body && typeof body === 'object') ? body : {}; + const inputs = (b.inputs ?? b.variables); + const signal: any = {}; + if (inputs && typeof inputs === 'object') signal.variables = inputs; + if (b.output && typeof b.output === 'object') signal.output = b.output; + if (typeof b.branchLabel === 'string') signal.branchLabel = b.branchLabel; + const result = await automationService.resume(parts[2], signal); + return { handled: true, response: deps.success(result) }; + } + return { handled: true, response: deps.error('Resume not supported', 501) }; + } + + // GET /:name/runs/:runId/screen → the screen a paused run awaits + // (refresh-safe re-fetch for the UI flow-runner). + if (parts[1] === 'runs' && parts[2] && parts[3] === 'screen' && m === 'GET') { + if (typeof automationService.getSuspendedScreen === 'function') { + const screen = automationService.getSuspendedScreen(parts[2]); + if (!screen) return { handled: true, response: deps.error('No pending screen for run', 404) }; + return { handled: true, response: deps.success({ runId: parts[2], screen }) }; + } + return { handled: true, response: deps.error('Screen lookup not supported', 501) }; + } + + // GET /:name/runs/:runId → getRun + if (parts[1] === 'runs' && parts[2] && !parts[3] && m === 'GET') { + if (typeof automationService.getRun === 'function') { + const run = await automationService.getRun(parts[2]); + if (!run) return { handled: true, response: deps.error('Execution not found', 404) }; + return { handled: true, response: deps.success(run) }; + } + } + + // GET /:name/runs → listRuns + if (parts[1] === 'runs' && !parts[2] && m === 'GET') { + if (typeof automationService.listRuns === 'function') { + const options = query ? { limit: query.limit ? Number(query.limit) : undefined, cursor: query.cursor } : undefined; + const runs = await automationService.listRuns(name, options); + return { handled: true, response: deps.success({ runs, hasMore: false }) }; + } + } + + // GET /:name → getFlow (no sub-path) + if (parts.length === 1 && m === 'GET') { + if (typeof automationService.getFlow === 'function') { + const flow = await automationService.getFlow(name); + if (!flow) return { handled: true, response: deps.error('Flow not found', 404) }; + return { handled: true, response: deps.success(flow) }; + } + } + + // PUT /:name → updateFlow + if (parts.length === 1 && m === 'PUT') { + if (typeof automationService.registerFlow === 'function') { + automationService.registerFlow(name, body?.definition ?? body); + return { handled: true, response: deps.success(body?.definition ?? body) }; + } + } + + // DELETE /:name → deleteFlow + if (parts.length === 1 && m === 'DELETE') { + if (typeof automationService.unregisterFlow === 'function') { + automationService.unregisterFlow(name); + return { handled: true, response: deps.success({ name, deleted: true }) }; + } + } + } + + return { handled: false }; +} diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 7c609b0b84..f215666d4d 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -23,6 +23,7 @@ import { createStorageDomain, handleStorageRequest } from './domains/storage.js' import { createUiDomain, handleUiRequest } from './domains/ui.js'; import { createShareLinksDomain, handleShareLinksRequest } from './domains/share-links.js'; import { createPackagesDomain, handlePackagesRequest } from './domains/packages.js'; +import { createAutomationDomain, handleAutomationRequest } from './domains/automation.js'; /** Minimal local interface — full EnvironmentScopeManager was removed in Phase R. */ interface EnvironmentScopeManager { @@ -312,6 +313,7 @@ export class HttpDispatcher { this.domainRegistry.register(createUiDomain(this.domainDeps)); this.domainRegistry.register(createShareLinksDomain(this.domainDeps)); this.domainRegistry.register(createPackagesDomain(this.domainDeps)); + this.domainRegistry.register(createAutomationDomain(this.domainDeps)); } /** @@ -2644,264 +2646,9 @@ export class HttpDispatcher { return handleUiRequest(this.domainDeps, path, query, _context); } - /** - * Handles Automation requests - * path: sub-path after /automation/ - * - * Routes: - * GET / → listFlows - * GET /actions → getActionDescriptors (ADR-0018; ?paradigm/?source/?category filters) - * GET /connectors → getConnectorDescriptors (ADR-0022; ?type filter) - * GET /:name → getFlow - * POST / → createFlow (registerFlow) - * PUT /:name → updateFlow - * DELETE /:name → deleteFlow (unregisterFlow) - * POST /:name/trigger → execute (legacy: trigger/:name also supported) - * POST /:name/toggle → toggleFlow - * GET /:name/runs → listRuns - * GET /:name/runs/:runId → getRun - * POST /:name/runs/:runId/resume → resume a paused run (screen input / ADR-0019) - * GET /:name/runs/:runId/screen → the screen a paused run awaits - */ + /** Thin delegate — body extracted to `./domains/automation.ts` (D11③ PR-6). */ async handleAutomation(path: string, method: string, body: any, context: HttpProtocolContext, query?: any): Promise { - const automationService = await this.getService(CoreServiceName.enum.automation); - if (!automationService) return { handled: false }; - - const m = method.toUpperCase(); - const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); - - // Legacy: POST /automation/trigger/:name - if (parts[0] === 'trigger' && parts[1] && m === 'POST') { - const triggerName = parts[1]; - if (typeof automationService.trigger === 'function') { - const result = await automationService.trigger(triggerName, body, { request: context.request }); - return { handled: true, response: this.success(result) }; - } - // Fallback to execute - if (typeof automationService.execute === 'function') { - const result = await automationService.execute(triggerName, body); - return { handled: true, response: this.success(result) }; - } - } - - // GET / → listFlows - if (parts.length === 0 && m === 'GET') { - if (typeof automationService.listFlows === 'function') { - const names = await automationService.listFlows(); - return { handled: true, response: this.success({ flows: names, total: names.length, hasMore: false }) }; - } - } - - // POST / → createFlow - if (parts.length === 0 && m === 'POST') { - if (typeof automationService.registerFlow === 'function') { - automationService.registerFlow(body?.name, body); - return { handled: true, response: this.success(body) }; - } - } - - // GET /actions → list registered action descriptors (ADR-0018). - // MUST precede the `/:name → getFlow` catch-all below, otherwise a - // flow lookup for a flow literally named "actions" would shadow it. - // Backs the designer palette + flow validation; the registry is open - // and marketplace-extensible (built-in + plugin-contributed actions). - if (parts[0] === 'actions' && parts.length === 1 && m === 'GET') { - if (typeof automationService.getActionDescriptors === 'function') { - let actions = automationService.getActionDescriptors() ?? []; - // Optional filters mirror descriptor fields. - if (query?.paradigm) { - actions = actions.filter((a: any) => Array.isArray(a?.paradigms) && a.paradigms.includes(query.paradigm)); - } - if (query?.source) { - actions = actions.filter((a: any) => a?.source === query.source); - } - if (query?.category) { - actions = actions.filter((a: any) => a?.category === query.category); - } - return { handled: true, response: this.success({ actions, total: actions.length }) }; - } - // Service present but does not implement the optional method: - // report an empty (but valid) registry rather than a 404. - return { handled: true, response: this.success({ actions: [], total: 0 }) }; - } - - // GET /connectors → list registered connector descriptors (ADR-0022). - // Like /actions, MUST precede the `/:name → getFlow` catch-all so a flow - // named "connectors" cannot shadow it. Backs the designer's - // `connector_action` connector/action/input pickers; the registry is - // empty in baseline and populated by connector plugins (e.g. - // @objectstack/connector-rest, @objectstack/connector-slack). - if (parts[0] === 'connectors' && parts.length === 1 && m === 'GET') { - if (typeof automationService.getConnectorDescriptors === 'function') { - let connectors = automationService.getConnectorDescriptors() ?? []; - // Optional filter mirrors the descriptor's connector type. - if (query?.type) { - connectors = connectors.filter((c: any) => c?.type === query.type); - } - return { handled: true, response: this.success({ connectors, total: connectors.length }) }; - } - // Service present but does not implement the optional method: - // report an empty (but valid) registry rather than a 404. - return { handled: true, response: this.success({ connectors: [], total: 0 }) }; - } - - // GET /_status → runtime enable/bound state for every flow (backs the - // Studio's Automations status badges: persisted `status` is metadata, but - // whether a flow is actually enabled + bound to its trigger is engine - // state). Underscore-prefixed so no flow name can shadow it; MUST precede - // the `/:name → getFlow` catch-all. - if (parts[0] === '_status' && parts.length === 1 && m === 'GET') { - const svc = automationService as { getFlowRuntimeStates?: () => Array<{ name: string; enabled: boolean; bound: boolean }> }; - if (typeof svc.getFlowRuntimeStates === 'function') { - const flows = svc.getFlowRuntimeStates(); - return { handled: true, response: this.success({ flows, total: flows.length }) }; - } - // Service present but older / does not implement the method. - return { handled: true, response: this.success({ flows: [], total: 0 }) }; - } - - // Routes with :name - if (parts.length >= 1) { - const name = parts[0]; - - // POST /:name/trigger → execute - if (parts[1] === 'trigger' && m === 'POST') { - if (typeof automationService.execute === 'function') { - const ctxBody = body && typeof body === 'object' ? body : {}; - // Translate UI/SDK request shape `{recordId, objectName, params}` - // into the canonical AutomationContext shape expected by the engine. - // Key transformations: - // - `recordId` is exposed in `params.recordId` AND aliased to - // `Id` (camelCase) so flow variables like `leadId`, - // `caseId`, `opportunityId` resolve from a single REST contract. - // - `objectName` is mapped to the canonical `object` field. - // - The user identity from the auth context (if any) is forwarded - // as `userId` so node executors / template interpolation can - // expand `{$User.Id}`. - const recordId = ctxBody.recordId; - const objectName = ctxBody.objectName ?? ctxBody.object; - const baseParams = (ctxBody.params && typeof ctxBody.params === 'object') ? { ...ctxBody.params } : {}; - // Back-compat: when callers POST a flat body (no `params` wrapper), - // forward unknown top-level keys as flow params so the original - // `{ foo: 'bar' }` payload is not silently dropped. - if (!ctxBody.params) { - const reserved = new Set(['recordId', 'objectName', 'object', 'event', 'params']); - for (const [k, v] of Object.entries(ctxBody)) { - if (reserved.has(k)) continue; - if (baseParams[k] === undefined) baseParams[k] = v; - } - } - if (recordId !== undefined && baseParams.recordId === undefined) { - baseParams.recordId = recordId; - } - if (recordId !== undefined && objectName) { - const alias = `${String(objectName).replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase())}Id`; - if (baseParams[alias] === undefined) baseParams[alias] = recordId; - } - const automationContext: any = { - params: baseParams, - object: objectName, - event: ctxBody.event ?? 'manual', - }; - // Forward the FULLY-RESOLVED caller identity (not just the - // user id) so a `runAs:'user'` flow enforces RLS exactly as the - // triggering user — their roles/tenant, not a member fallback - // (#1888). The engine elevates to a system principal only when - // the flow itself declares `runAs:'system'`. - const ec = (context as any)?.executionContext; - const userIdFromAuth = (context as any)?.user?.id ?? (context as any)?.userId ?? ec?.userId; - if (userIdFromAuth) automationContext.userId = userIdFromAuth; - if (Array.isArray(ec?.positions) && ec.positions.length) automationContext.positions = ec.positions; - if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions; - if (ec?.tenantId) automationContext.tenantId = ec.tenantId; - const result = await automationService.execute(name, automationContext); - return { handled: true, response: this.success(result) }; - } - } - - // POST /:name/toggle → toggleFlow - if (parts[1] === 'toggle' && m === 'POST') { - if (typeof automationService.toggleFlow === 'function') { - await automationService.toggleFlow(name, body?.enabled ?? true); - return { handled: true, response: this.success({ name, enabled: body?.enabled ?? true }) }; - } - } - - // POST /:name/runs/:runId/resume → resume a paused run (screen-flow - // runtime / ADR-0019). Body `{ inputs }` = a screen node's collected - // values, applied as bare flow variables; `output`/`branchLabel` also - // forwarded for approval-style resumes. Returns the next paused - // `{ screen }` (multi-screen) or the completed result. - if (parts[1] === 'runs' && parts[2] && parts[3] === 'resume' && m === 'POST') { - if (typeof automationService.resume === 'function') { - const b = (body && typeof body === 'object') ? body : {}; - const inputs = (b.inputs ?? b.variables); - const signal: any = {}; - if (inputs && typeof inputs === 'object') signal.variables = inputs; - if (b.output && typeof b.output === 'object') signal.output = b.output; - if (typeof b.branchLabel === 'string') signal.branchLabel = b.branchLabel; - const result = await automationService.resume(parts[2], signal); - return { handled: true, response: this.success(result) }; - } - return { handled: true, response: this.error('Resume not supported', 501) }; - } - - // GET /:name/runs/:runId/screen → the screen a paused run awaits - // (refresh-safe re-fetch for the UI flow-runner). - if (parts[1] === 'runs' && parts[2] && parts[3] === 'screen' && m === 'GET') { - if (typeof automationService.getSuspendedScreen === 'function') { - const screen = automationService.getSuspendedScreen(parts[2]); - if (!screen) return { handled: true, response: this.error('No pending screen for run', 404) }; - return { handled: true, response: this.success({ runId: parts[2], screen }) }; - } - return { handled: true, response: this.error('Screen lookup not supported', 501) }; - } - - // GET /:name/runs/:runId → getRun - if (parts[1] === 'runs' && parts[2] && !parts[3] && m === 'GET') { - if (typeof automationService.getRun === 'function') { - const run = await automationService.getRun(parts[2]); - if (!run) return { handled: true, response: this.error('Execution not found', 404) }; - return { handled: true, response: this.success(run) }; - } - } - - // GET /:name/runs → listRuns - if (parts[1] === 'runs' && !parts[2] && m === 'GET') { - if (typeof automationService.listRuns === 'function') { - const options = query ? { limit: query.limit ? Number(query.limit) : undefined, cursor: query.cursor } : undefined; - const runs = await automationService.listRuns(name, options); - return { handled: true, response: this.success({ runs, hasMore: false }) }; - } - } - - // GET /:name → getFlow (no sub-path) - if (parts.length === 1 && m === 'GET') { - if (typeof automationService.getFlow === 'function') { - const flow = await automationService.getFlow(name); - if (!flow) return { handled: true, response: this.error('Flow not found', 404) }; - return { handled: true, response: this.success(flow) }; - } - } - - // PUT /:name → updateFlow - if (parts.length === 1 && m === 'PUT') { - if (typeof automationService.registerFlow === 'function') { - automationService.registerFlow(name, body?.definition ?? body); - return { handled: true, response: this.success(body?.definition ?? body) }; - } - } - - // DELETE /:name → deleteFlow - if (parts.length === 1 && m === 'DELETE') { - if (typeof automationService.unregisterFlow === 'function') { - automationService.unregisterFlow(name); - return { handled: true, response: this.success({ name, deleted: true }) }; - } - } - } - - return { handled: false }; + return handleAutomationRequest(this.domainDeps, path, method, body, context, query); } private getServicesMap(): Record { @@ -3506,9 +3253,7 @@ export class HttpDispatcher { // /storage and /ui moved to the domain registry (D11 step ③). - if (cleanPath.startsWith('/automation')) { - return this.handleAutomation(cleanPath.substring(11), method, body, context, query); - } + // /automation moved to the domain registry (D11 step ③). if (cleanPath.startsWith('/actions')) { return this.handleActions(cleanPath.substring(8), method, body, context);