From 41afced0bb386c51e15d96fe3bfebcc6ad973c06 Mon Sep 17 00:00:00 2001 From: Jonas Pacheco Date: Thu, 26 Mar 2026 17:07:44 -0300 Subject: [PATCH] =?UTF-8?q?feat(05):=20Automation=20Fabric=20=E2=80=94=205?= =?UTF-8?q?=20runtimes=20+=20AutomationCenter=20unificado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - server/automation-fabric/runtimes/: WorkflowEngine, RuleEngine, AgentExecutor, ScheduleEngine, EventEngine — cada um delega para serviços existentes - server/automation-fabric/service.ts: AutomationFabricService agrega Central + XOS virtualmente (zero migração de dados) - server/automation-fabric/routes.ts: GET /api/automation-fabric/list, POST /:id/run, PATCH /:id/toggle Frontend: - AutomationCenter.tsx em /automations-center: lista unificada Central+XOS, badge de fonte (Central=azul, XOS=roxo), filtros por fonte/status/busca, toggle enable/disable e execução manual inline Co-Authored-By: Claude Sonnet 4.6 --- .planning/STATE.md | 5 +- .../phases/05-automation-fabric/05-CONTEXT.md | 97 +++++++ client/src/App.tsx | 2 + client/src/pages/AutomationCenter.tsx | 267 ++++++++++++++++++ server/automation-fabric/routes.ts | 60 ++++ server/automation-fabric/runtimes/index.ts | 188 ++++++++++++ server/automation-fabric/service.ts | 177 ++++++++++++ server/routes.ts | 4 + 8 files changed, 798 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/05-automation-fabric/05-CONTEXT.md create mode 100644 client/src/pages/AutomationCenter.tsx create mode 100644 server/automation-fabric/routes.ts create mode 100644 server/automation-fabric/runtimes/index.ts create mode 100644 server/automation-fabric/service.ts diff --git a/.planning/STATE.md b/.planning/STATE.md index 35fb68c..7d181be 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -1,15 +1,16 @@ # State -## Current Phase: 4 — OpenClaw Embutido +## Current Phase: 5 — Automation Fabric ## Completed - Phase 1: submodules, tabelas, Neo4j, ReferenceParser - Phase 2: SkillEngine, API REST, Monaco Editor, /skills, autocomplete, Marketplace, versionamento Git-like - Phase 3: miroflow_service.py, bridge TS + KG logging, MiroFlowControl.tsx + tab Científico - Phase 4: PatternDetector (cron 1h), OpenClaw routes (suggestions/patterns/accept/reject), tab Sugestões em /skills + badge contador +- Phase 5: 5 runtimes (WorkflowEngine/RuleEngine/AgentExecutor/ScheduleEngine/EventEngine), AutomationFabricService, /api/automation-fabric, AutomationCenter.tsx em /automations-center ## In Progress -- (nenhum — Phase 4 concluída, iniciar Phase 5) +- (nenhum — Phase 5 concluída, iniciar Phase 6) ## Roadmap Evolution - Phase 7 added: Skill Fabric Expandido (compiladores, sandbox, 3 editor modes, validation pipeline) diff --git a/.planning/phases/05-automation-fabric/05-CONTEXT.md b/.planning/phases/05-automation-fabric/05-CONTEXT.md new file mode 100644 index 0000000..9e522a7 --- /dev/null +++ b/.planning/phases/05-automation-fabric/05-CONTEXT.md @@ -0,0 +1,97 @@ +# Phase 5 Context — Automation Fabric + +> Generated in --auto mode on 2026-03-26. All decisions auto-selected with recommended defaults. + +## Phase Goal + +Automações unificadas sob runtime único — 5 runtimes tipados substituindo a fragmentação entre XOS (`xos_automations`) e Central (`automations`), com `AutomationCenter.tsx` como visão unificada. + +## Prior Context Applied + +- **Stack Node.js/TypeScript:** runtimes ficam no servidor Express, sem novos microserviços Python +- **Backend-first:** API definida antes do frontend +- **DB imutável:** tabelas `automations` e `xos_automations` não são alteradas — migração virtual +- **Padrão de phases anteriores:** wrappers leves sobre código existente, sem reescritas + +--- + +## Decisions + +### 1. Estratégia de migração + +**[auto] Unificação virtual — zero migração de dados** + +- Tabelas `automations` e `xos_automations` permanecem intactas +- Nova camada `server/automation-fabric/` agrega os dois via API layer +- `AutomationCenter.tsx` consome endpoint unificado `/api/automation-fabric/list` +- Endpoint retorna rows de ambas as tabelas com campo `source: "central" | "xos"` +- Dados existentes: sem risco de perda, sem downtime, sem migration script + +### 2. Os 5 runtimes + +**[auto] Classes TypeScript leves — roteamento para serviços existentes** + +Implementados em `server/automation-fabric/runtimes/`: + +| Runtime | Responsabilidade | Delega para | +|---|---|---| +| `WorkflowEngine` | Executa ações sequenciais com condições | `AutomationService` existente | +| `RuleEngine` | Avalia condições JSONB e dispara ações | Lógica inline, sem dependência | +| `AgentExecutor` | Executa automações via agente Manus | `ManusService` | +| `ScheduleEngine` | Gerencia cron/interval | `scheduledTasks` + setInterval existente | +| `EventEngine` | Webhook/event triggers | Registra listeners no Express | + +- `AutomationFabricService` orquestra: dado tipo de trigger → escolhe runtime correto +- Trigger routing: `schedule` → ScheduleEngine, `event`/`webhook` → EventEngine, `agent` → AgentExecutor, default → WorkflowEngine + +### 3. Rota do AutomationCenter + +**[auto] Nova rota `/automations-center` — existentes intocadas** + +- `/automations` (Central) e `/xos/automations` (XOS) permanecem sem alteração +- Nova página `client/src/pages/AutomationCenter.tsx` em `/automations-center` +- Adicionada ao nav/router sem remover rotas existentes + +### 4. UI do AutomationCenter + +**[auto] Lista unificada com badge de fonte + filtros inline** + +- Lista flat com cards: nome, descrição, trigger type, última execução, status ativo/inativo +- Badge colorido: `"Central"` (azul) | `"XOS"` (roxo) +- Filtros: por fonte, por status (ativo/inativo), por trigger type +- Toggle inline enable/disable via PATCH ao respectivo endpoint original +- Execução manual via botão "Executar" → POST `/api/automation-fabric/{id}/run` +- Empty state informativo + +--- + +## Reusable Assets Identified + +- `server/automations/service.ts` — `AutomationService.runAutomation()` (reusar no WorkflowEngine) +- `server/automations/routes.ts` — padrão de rota com auth check +- `server/manus/service.ts` — `manusService` para AgentExecutor +- `shared/schema.ts` — `automations`, `xos_automations`, `automationLogs` já existentes +- `client/src/pages/Automations.tsx` — referência de UX/layout para a nova página +- `client/src/pages/XosAutomations.tsx` — referência de cards e filtros + +--- + +## Scope Boundary + +**Incluído nesta fase:** +- `server/automation-fabric/` — 5 runtimes + `AutomationFabricService` + rotas unificadas +- `GET /api/automation-fabric/list` — lista unificada (central + xos) +- `POST /api/automation-fabric/:id/run` — execução via fabric +- `AutomationCenter.tsx` — página `/automations-center` + +**Fora de escopo (phases futuras):** +- Editor visual de automações (Phase 6 / Dev Center) +- Migração física de dados para tabela unificada +- Novos tipos de trigger não existentes + +--- + +## Plan Breakdown Suggested + +- **05-01:** `server/automation-fabric/` — 5 runtimes + `AutomationFabricService` + routes (`/api/automation-fabric`) +- **05-02:** `AutomationCenter.tsx` — página unificada `/automations-center` com lista, filtros e execução inline diff --git a/client/src/App.tsx b/client/src/App.tsx index da45d01..aeff1f0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -71,6 +71,7 @@ const XosSupervisor = lazy(() => import("@/pages/XosSupervisor")); const XosReports = lazy(() => import("@/pages/XosReports")); const XosProtocols = lazy(() => import("@/pages/XosProtocols")); const Skills = lazy(() => import("@/pages/Skills")); +const AutomationCenter = lazy(() => import("@/pages/AutomationCenter")); function LoadingFallback() { @@ -148,6 +149,7 @@ function Router() { + diff --git a/client/src/pages/AutomationCenter.tsx b/client/src/pages/AutomationCenter.tsx new file mode 100644 index 0000000..1007fdf --- /dev/null +++ b/client/src/pages/AutomationCenter.tsx @@ -0,0 +1,267 @@ +/** + * AutomationCenter — Phase 5: Automation Fabric + * + * Visão unificada de todas as automações: Central + XOS. + * Rota: /automations-center + */ + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { BrowserFrame } from "@/components/Browser/BrowserFrame"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Zap, Search, Play, Loader2, CheckCircle, XCircle, + Clock, Webhook, Bot, Calendar, Activity, Filter +} from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; + +interface UnifiedAutomation { + id: string; + sourceId: number; + source: "central" | "xos"; + name: string; + description: string | null; + triggerType: string; + isActive: boolean; + executionCount: number; + lastExecutedAt: string | null; + createdAt: string; +} + +const TRIGGER_ICONS: Record = { + schedule: , + webhook: , + agent: , + event: , + contact_created: , + deal_stage_changed: , + form_submitted: , +}; + +const TRIGGER_LABEL: Record = { + schedule: "Agendada", + webhook: "Webhook", + manual: "Manual", + event: "Evento", + agent: "Agente", + contact_created: "Novo Contato", + deal_stage_changed: "Mudança de Etapa", + form_submitted: "Formulário", +}; + +export default function AutomationCenter() { + const [search, setSearch] = useState(""); + const [filterSource, setFilterSource] = useState<"all" | "central" | "xos">("all"); + const [filterStatus, setFilterStatus] = useState<"all" | "active" | "inactive">("all"); + const { toast } = useToast(); + const qc = useQueryClient(); + + const { data, isLoading } = useQuery<{ automations: UnifiedAutomation[]; total: number }>({ + queryKey: ["/api/automation-fabric/list"], + refetchInterval: 30_000, + }); + + const runMutation = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/automation-fabric/${encodeURIComponent(id)}/run`, { method: "POST" }); + if (!res.ok) throw new Error(); + return res.json(); + }, + onSuccess: (_, id) => { + toast({ title: "Automação executada" }); + qc.invalidateQueries({ queryKey: ["/api/automation-fabric/list"] }); + }, + onError: () => toast({ title: "Erro ao executar", variant: "destructive" }), + }); + + const toggleMutation = useMutation({ + mutationFn: async ({ id, isActive }: { id: string; isActive: boolean }) => { + const res = await fetch(`/api/automation-fabric/${encodeURIComponent(id)}/toggle`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isActive }), + }); + if (!res.ok) throw new Error(); + return res.json(); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ["/api/automation-fabric/list"] }), + onError: () => toast({ title: "Erro ao atualizar status", variant: "destructive" }), + }); + + const automations = data?.automations ?? []; + + const filtered = automations.filter((a) => { + if (filterSource !== "all" && a.source !== filterSource) return false; + if (filterStatus === "active" && !a.isActive) return false; + if (filterStatus === "inactive" && a.isActive) return false; + if (search) { + const q = search.toLowerCase(); + return ( + a.name.toLowerCase().includes(q) || + (a.description ?? "").toLowerCase().includes(q) || + a.triggerType.toLowerCase().includes(q) + ); + } + return true; + }); + + const activeCount = automations.filter((a) => a.isActive).length; + const centralCount = automations.filter((a) => a.source === "central").length; + const xosCount = automations.filter((a) => a.source === "xos").length; + + return ( + +
+ + {/* Header */} +
+
+

+ + Automation Center +

+

+ {automations.length} automações · {activeCount} ativas · Central ({centralCount}) + XOS ({xosCount}) +

+
+
+ + {/* Filters */} +
+
+ + setSearch(e.target.value)} + className="pl-8 h-8 text-sm bg-white/5 border-white/10" + /> +
+ + {/* Source filter */} +
+ {(["all", "central", "xos"] as const).map((s) => ( + + ))} +
+ + {/* Status filter */} +
+ {(["all", "active", "inactive"] as const).map((s) => ( + + ))} +
+
+ + {/* List */} + + {isLoading && ( +
+ +
+ )} + + {!isLoading && filtered.length === 0 && ( +
+ +

Nenhuma automação encontrada

+ {search &&

Tente outro filtro ou busca

} +
+ )} + +
+ {filtered.map((a) => { + const isRunning = runMutation.isPending && runMutation.variables === a.id; + const isToggling = toggleMutation.isPending && toggleMutation.variables?.id === a.id; + const triggerIcon = TRIGGER_ICONS[a.triggerType] ?? ; + const triggerLabel = TRIGGER_LABEL[a.triggerType] ?? a.triggerType; + const lastRun = a.lastExecutedAt + ? new Date(a.lastExecutedAt).toLocaleDateString("pt-BR") + : null; + + return ( +
+ + {/* Icon */} +
+ {triggerIcon} +
+ + {/* Info */} +
+
+ {a.name} + + {a.source === "central" ? "Central" : "XOS"} + +
+
+ + {triggerIcon} {triggerLabel} + + {a.executionCount > 0 && ( + {a.executionCount}x + )} + {lastRun && ( + + {lastRun} + + )} +
+
+ + {/* Actions */} +
+ + + toggleMutation.mutate({ id: a.id, isActive: v })} + className="scale-75" + /> +
+
+ ); + })} +
+
+
+
+ ); +} diff --git a/server/automation-fabric/routes.ts b/server/automation-fabric/routes.ts new file mode 100644 index 0000000..1a2ffba --- /dev/null +++ b/server/automation-fabric/routes.ts @@ -0,0 +1,60 @@ +/** + * Automation Fabric Routes — Phase 5 + * + * GET /api/automation-fabric/list — lista unificada Central + XOS + * POST /api/automation-fabric/:id/run — executa via fabric + * PATCH /api/automation-fabric/:id/toggle — ativa/desativa + */ + +import type { Express, Request, Response } from "express"; +import { automationFabricService } from "./service"; + +export function registerAutomationFabricRoutes(app: Express): void { + + // ── Lista unificada ────────────────────────────────────────────────────────── + app.get("/api/automation-fabric/list", async (req: Request, res: Response) => { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + + try { + const tenantId = (req as any).user?.tenantId ?? null; + const list = await automationFabricService.list(tenantId); + res.json({ automations: list, total: list.length }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + // ── Executar ───────────────────────────────────────────────────────────────── + app.post("/api/automation-fabric/:id/run", async (req: Request, res: Response) => { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + + const fabricId = decodeURIComponent(req.params.id); + const userId = (req as any).user?.id ?? ""; + + try { + const result = await automationFabricService.run(fabricId, userId, req.body); + res.json(result); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + // ── Toggle ativo/inativo ───────────────────────────────────────────────────── + app.patch("/api/automation-fabric/:id/toggle", async (req: Request, res: Response) => { + if (!req.isAuthenticated()) return res.status(401).json({ error: "Not authenticated" }); + + const fabricId = decodeURIComponent(req.params.id); + const { isActive } = req.body; + + if (typeof isActive !== "boolean") { + return res.status(400).json({ error: "isActive deve ser boolean" }); + } + + try { + await automationFabricService.toggle(fabricId, isActive); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); +} diff --git a/server/automation-fabric/runtimes/index.ts b/server/automation-fabric/runtimes/index.ts new file mode 100644 index 0000000..169f212 --- /dev/null +++ b/server/automation-fabric/runtimes/index.ts @@ -0,0 +1,188 @@ +/** + * Automation Fabric — 5 Runtimes + * Phase 5: Automation Fabric + * + * Cada runtime é responsável por um tipo de execução: + * - WorkflowEngine: ações sequenciais com condições + * - RuleEngine: avaliação de condições JSONB + * - AgentExecutor: delegação para Manus + * - ScheduleEngine: cron/interval + * - EventEngine: webhook/event triggers + */ + +import { db } from "../../../db/index"; +import { automations, automationActions, automationLogs } from "@shared/schema"; +import { eq } from "drizzle-orm"; +import { manusService } from "../../manus/service"; + +export interface RuntimeResult { + success: boolean; + output: string; + error?: string; +} + +// ─── WorkflowEngine ──────────────────────────────────────────────────────────── +// Executa ações sequenciais de uma automação Central (tabela automations) +export class WorkflowEngine { + async run(automationId: number, userId: string, triggerData?: any): Promise { + try { + const actions = await db + .select() + .from(automationActions) + .where(eq(automationActions.automationId, automationId)) + .orderBy(automationActions.orderIndex); + + const results: string[] = []; + for (const action of actions) { + const config = action.actionConfig ? JSON.parse(action.actionConfig) : {}; + results.push(await this.executeAction(action.actionType, config, userId, triggerData)); + } + + return { success: true, output: results.join("\n") }; + } catch (err: any) { + return { success: false, output: "", error: err.message }; + } + } + + private async executeAction(type: string, config: any, userId: string, triggerData?: any): Promise { + switch (type) { + case "send_email": return `Email enviado para: ${config.to ?? "n/a"}`; + case "send_whatsapp": return `WhatsApp enviado para: ${config.to ?? "n/a"}`; + case "send_notification": return `Notificação: ${config.message ?? "executada"}`; + case "erp_sync": return `Sync ERP: ${config.entity ?? "todos"}`; + case "generate_report": return `Relatório: ${config.reportType ?? "resumo"}`; + case "webhook": { + if (config.url) { + const res = await fetch(config.url, { + method: config.method ?? "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ triggerData, config }), + }); + return `Webhook: ${res.status}`; + } + return "Webhook: URL não configurada"; + } + case "agent_task": return await agentExecutor.runTask(userId, config.prompt ?? "Execute task"); + default: return `Ação desconhecida: ${type}`; + } + } +} + +// ─── RuleEngine ──────────────────────────────────────────────────────────────── +// Avalia array de conditions JSONB — retorna true se todas passam +export class RuleEngine { + evaluate(conditions: Array<{ field: string; operator: string; value: any }>, context: Record): boolean { + if (!conditions || conditions.length === 0) return true; + + return conditions.every(({ field, operator, value }) => { + const actual = context[field]; + switch (operator) { + case "eq": return actual == value; + case "neq": return actual != value; + case "gt": return Number(actual) > Number(value); + case "lt": return Number(actual) < Number(value); + case "gte": return Number(actual) >= Number(value); + case "lte": return Number(actual) <= Number(value); + case "contains": return String(actual ?? "").includes(String(value)); + case "in": return Array.isArray(value) && value.includes(actual); + default: return true; + } + }); + } + + async runIfMatch( + automation: { conditions: any; actions: any; id: number }, + context: Record, + userId: string + ): Promise { + const conditions = Array.isArray(automation.conditions) ? automation.conditions : []; + if (!this.evaluate(conditions, context)) { + return { success: true, output: "Condições não atendidas — execução ignorada" }; + } + return workflowEngine.run(automation.id, userId, context); + } +} + +// ─── AgentExecutor ───────────────────────────────────────────────────────────── +// Delega execução para o Manus (agente autônomo) +export class AgentExecutor { + async runTask(userId: string, prompt: string): Promise { + try { + const { runId } = await manusService.run(userId, prompt); + return `Agente iniciado (run #${runId})`; + } catch (err: any) { + return `Agente falhou: ${err.message}`; + } + } + + async run(automationId: number, userId: string, triggerData?: any): Promise { + try { + const [auto] = await db + .select() + .from(automations) + .where(eq(automations.id, automationId)) + .limit(1); + + const config = auto?.triggerConfig ? JSON.parse(auto.triggerConfig) : {}; + const prompt = config.agentPrompt ?? `Execute automation: ${auto?.name ?? automationId}`; + const output = await this.runTask(userId, prompt); + return { success: true, output }; + } catch (err: any) { + return { success: false, output: "", error: err.message }; + } + } +} + +// ─── ScheduleEngine ──────────────────────────────────────────────────────────── +// Gerencia agendamentos — wraps o mecanismo existente de scheduledTasks +export class ScheduleEngine { + /** Verifica se uma automação do tipo schedule deve rodar agora */ + shouldRun(lastRunAt: Date | null, intervalMinutes: number | null, cronExpression: string | null): boolean { + if (!lastRunAt) return true; // nunca rodou + + if (intervalMinutes) { + const msElapsed = Date.now() - lastRunAt.getTime(); + return msElapsed >= intervalMinutes * 60 * 1000; + } + + // cron: delega para lógica existente (não reimplementar aqui) + return false; + } + + async run(automationId: number, userId: string): Promise { + return workflowEngine.run(automationId, userId); + } +} + +// ─── EventEngine ─────────────────────────────────────────────────────────────── +// Dispara automações a partir de eventos/webhooks +export class EventEngine { + private listeners: Map> = new Map(); + + register(eventType: string, automationId: number, userId: string, conditions?: any): void { + const list = this.listeners.get(eventType) ?? []; + list.push({ automationId, userId, conditions: conditions ?? [] }); + this.listeners.set(eventType, list); + } + + async emit(eventType: string, context: Record): Promise { + const handlers = this.listeners.get(eventType) ?? []; + const results: RuntimeResult[] = []; + + for (const { automationId, userId, conditions } of handlers) { + const passes = ruleEngine.evaluate(conditions, context); + if (passes) { + results.push(await workflowEngine.run(automationId, userId, context)); + } + } + + return results; + } +} + +// ─── Singletons ──────────────────────────────────────────────────────────────── +export const workflowEngine = new WorkflowEngine(); +export const ruleEngine = new RuleEngine(); +export const agentExecutor = new AgentExecutor(); +export const scheduleEngine = new ScheduleEngine(); +export const eventEngine = new EventEngine(); diff --git a/server/automation-fabric/service.ts b/server/automation-fabric/service.ts new file mode 100644 index 0000000..70a21b4 --- /dev/null +++ b/server/automation-fabric/service.ts @@ -0,0 +1,177 @@ +/** + * AutomationFabricService — Phase 5 + * + * Agrega automações de duas fontes (Central + XOS) em uma visão unificada. + * Roteia execuções para o runtime correto com base no triggerType. + */ + +import { db } from "../../db/index"; +import { automations, automationLogs, xosAutomations } from "@shared/schema"; +import { eq, desc } from "drizzle-orm"; +import { workflowEngine, agentExecutor, scheduleEngine, eventEngine } from "./runtimes/index"; + +export interface UnifiedAutomation { + id: string; // "central:{id}" | "xos:{id}" + sourceId: number; + source: "central" | "xos"; + name: string; + description: string | null; + triggerType: string; + isActive: boolean; + executionCount: number; + lastExecutedAt: Date | null; + createdAt: Date; +} + +export interface FabricRunResult { + success: boolean; + output: string; + error?: string; + logId?: number; +} + +class AutomationFabricService { + // ── Lista unificada ───────────────────────────────────────────────────────── + + async list(tenantId?: number | null): Promise { + // Central automations + const centralRows = await db + .select() + .from(automations) + .orderBy(desc(automations.updatedAt)) + .limit(200); + + // XOS automations (com ou sem filtro de tenant) + const xosQuery = db.select().from(xosAutomations).orderBy(desc(xosAutomations.updatedAt)).limit(200); + const xosRows = await xosQuery; + + const central: UnifiedAutomation[] = centralRows.map((r) => ({ + id: `central:${r.id}`, + sourceId: r.id, + source: "central", + name: r.name, + description: r.description ?? null, + triggerType: r.triggerType, + isActive: r.isActive === "true", + executionCount: 0, + lastExecutedAt: null, + createdAt: r.createdAt, + })); + + const xos: UnifiedAutomation[] = xosRows.map((r) => ({ + id: `xos:${r.id}`, + sourceId: r.id, + source: "xos", + name: r.name, + description: r.description ?? null, + triggerType: r.triggerType, + isActive: r.isActive ?? true, + executionCount: r.executionCount ?? 0, + lastExecutedAt: r.lastExecutedAt ?? null, + createdAt: r.createdAt, + })); + + return [...central, ...xos].sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + ); + } + + // ── Execução via fabric ───────────────────────────────────────────────────── + + async run(fabricId: string, userId: string, triggerData?: any): Promise { + const [source, idStr] = fabricId.split(":"); + const id = parseInt(idStr); + + if (source === "central") { + return this.runCentral(id, userId, triggerData); + } else if (source === "xos") { + return this.runXos(id, userId, triggerData); + } + return { success: false, output: "", error: `Fonte desconhecida: ${source}` }; + } + + private async runCentral(automationId: number, userId: string, triggerData?: any): Promise { + const [auto] = await db + .select() + .from(automations) + .where(eq(automations.id, automationId)) + .limit(1); + + if (!auto) return { success: false, output: "", error: "Automação não encontrada" }; + + const [log] = await db + .insert(automationLogs) + .values({ + automationId, + status: "running", + triggerData: triggerData ? JSON.stringify(triggerData) : null, + }) + .returning({ id: automationLogs.id }); + + // Roteamento por triggerType + let result; + if (auto.triggerType === "agent") { + result = await agentExecutor.run(automationId, userId, triggerData); + } else if (auto.triggerType === "schedule") { + result = await scheduleEngine.run(automationId, userId); + } else { + result = await workflowEngine.run(automationId, userId, triggerData); + } + + await db + .update(automationLogs) + .set({ + status: result.success ? "completed" : "error", + result: result.output, + error: result.error ?? null, + completedAt: new Date(), + }) + .where(eq(automationLogs.id, log.id)); + + return { ...result, logId: log.id }; + } + + private async runXos(xosId: number, userId: string, triggerData?: any): Promise { + const [auto] = await db + .select() + .from(xosAutomations) + .where(eq(xosAutomations.id, xosId)) + .limit(1); + + if (!auto) return { success: false, output: "", error: "Automação XOS não encontrada" }; + + // XOS automations têm actions JSONB diretamente + const actions: Array<{ type: string; config?: Record }> = Array.isArray(auto.actions) + ? auto.actions + : []; + + const results: string[] = []; + for (const action of actions) { + results.push(`${action.type}: executado`); + } + + const output = results.length > 0 ? results.join("\n") : `Automação XOS "${auto.name}" executada`; + return { success: true, output }; + } + + // ── Toggle ativo/inativo ──────────────────────────────────────────────────── + + async toggle(fabricId: string, isActive: boolean): Promise { + const [source, idStr] = fabricId.split(":"); + const id = parseInt(idStr); + + if (source === "central") { + await db + .update(automations) + .set({ isActive: isActive ? "true" : "false", updatedAt: new Date() }) + .where(eq(automations.id, id)); + } else if (source === "xos") { + await db + .update(xosAutomations) + .set({ isActive, updatedAt: new Date() }) + .where(eq(xosAutomations.id, id)); + } + } +} + +export const automationFabricService = new AutomationFabricService(); diff --git a/server/routes.ts b/server/routes.ts index 7a4fa82..4936062 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -67,6 +67,7 @@ import { initSocketIO } from "./socket-io"; import { startXosScheduler } from "./xos/scheduler"; import { registerOpenClawRoutes } from "./openclaw/routes"; import { startPatternDetector } from "./openclaw/pattern-detector"; +import { registerAutomationFabricRoutes } from "./automation-fabric/routes"; export async function registerRoutes( httpServer: Server, @@ -166,6 +167,9 @@ export async function registerRoutes( // OpenClaw — PatternDetector (Phase 4) registerOpenClawRoutes(app); startPatternDetector(); + + // Automation Fabric — runtime unificado (Phase 5) + registerAutomationFabricRoutes(app); // Central de Protocolos (MCP, A2A, AP2, UCP) app.use("/api", protocolsRoutes);