feat(05): Automation Fabric — 5 runtimes + AutomationCenter unificado
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 <noreply@anthropic.com>
This commit is contained in:
parent
9165ee4c48
commit
41afced0bb
|
|
@ -1,15 +1,16 @@
|
||||||
# State
|
# State
|
||||||
|
|
||||||
## Current Phase: 4 — OpenClaw Embutido
|
## Current Phase: 5 — Automation Fabric
|
||||||
|
|
||||||
## Completed
|
## Completed
|
||||||
- Phase 1: submodules, tabelas, Neo4j, ReferenceParser
|
- Phase 1: submodules, tabelas, Neo4j, ReferenceParser
|
||||||
- Phase 2: SkillEngine, API REST, Monaco Editor, /skills, autocomplete, Marketplace, versionamento Git-like
|
- 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 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 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
|
## In Progress
|
||||||
- (nenhum — Phase 4 concluída, iniciar Phase 5)
|
- (nenhum — Phase 5 concluída, iniciar Phase 6)
|
||||||
|
|
||||||
## Roadmap Evolution
|
## Roadmap Evolution
|
||||||
- Phase 7 added: Skill Fabric Expandido (compiladores, sandbox, 3 editor modes, validation pipeline)
|
- Phase 7 added: Skill Fabric Expandido (compiladores, sandbox, 3 editor modes, validation pipeline)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -71,6 +71,7 @@ const XosSupervisor = lazy(() => import("@/pages/XosSupervisor"));
|
||||||
const XosReports = lazy(() => import("@/pages/XosReports"));
|
const XosReports = lazy(() => import("@/pages/XosReports"));
|
||||||
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
|
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
|
||||||
const Skills = lazy(() => import("@/pages/Skills"));
|
const Skills = lazy(() => import("@/pages/Skills"));
|
||||||
|
const AutomationCenter = lazy(() => import("@/pages/AutomationCenter"));
|
||||||
|
|
||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
|
|
@ -148,6 +149,7 @@ function Router() {
|
||||||
<ProtectedRoute path="/migration" component={Migration} />
|
<ProtectedRoute path="/migration" component={Migration} />
|
||||||
<ProtectedRoute path="/dev-center" component={DevCenter} />
|
<ProtectedRoute path="/dev-center" component={DevCenter} />
|
||||||
<ProtectedRoute path="/skills" component={Skills} />
|
<ProtectedRoute path="/skills" component={Skills} />
|
||||||
|
<ProtectedRoute path="/automations-center" component={AutomationCenter} />
|
||||||
<ProtectedRoute path="/page/:id" component={WorkspacePage} />
|
<ProtectedRoute path="/page/:id" component={WorkspacePage} />
|
||||||
<ProtectedRoute path="/app/:id" component={AppViewer} />
|
<ProtectedRoute path="/app/:id" component={AppViewer} />
|
||||||
<Route path="/auth" component={AuthPage} />
|
<Route path="/auth" component={AuthPage} />
|
||||||
|
|
|
||||||
|
|
@ -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<string, React.ReactNode> = {
|
||||||
|
schedule: <Clock className="w-3.5 h-3.5" />,
|
||||||
|
webhook: <Webhook className="w-3.5 h-3.5" />,
|
||||||
|
agent: <Bot className="w-3.5 h-3.5" />,
|
||||||
|
event: <Activity className="w-3.5 h-3.5" />,
|
||||||
|
contact_created: <Zap className="w-3.5 h-3.5" />,
|
||||||
|
deal_stage_changed: <Zap className="w-3.5 h-3.5" />,
|
||||||
|
form_submitted: <Zap className="w-3.5 h-3.5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRIGGER_LABEL: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<BrowserFrame>
|
||||||
|
<div className="flex flex-col h-full bg-[#0a0f1a] text-white">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-[#c89b3c]" />
|
||||||
|
Automation Center
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{automations.length} automações · {activeCount} ativas · Central ({centralCount}) + XOS ({xosCount})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2 p-3 border-b border-white/10 flex-wrap">
|
||||||
|
<div className="relative flex-1 min-w-48">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Buscar automações..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-8 h-8 text-sm bg-white/5 border-white/10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source filter */}
|
||||||
|
<div className="flex rounded-md border border-white/10 overflow-hidden">
|
||||||
|
{(["all", "central", "xos"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setFilterSource(s)}
|
||||||
|
className={`px-2.5 py-1.5 text-xs transition-colors ${
|
||||||
|
filterSource === s ? "bg-[#c89b3c] text-black font-medium" : "text-white/50 hover:text-white hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === "all" ? "Todas" : s === "central" ? "Central" : "XOS"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<div className="flex rounded-md border border-white/10 overflow-hidden">
|
||||||
|
{(["all", "active", "inactive"] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setFilterStatus(s)}
|
||||||
|
className={`px-2.5 py-1.5 text-xs transition-colors ${
|
||||||
|
filterStatus === s ? "bg-[#c89b3c] text-black font-medium" : "text-white/50 hover:text-white hover:bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === "all" ? "Todas" : s === "active" ? "Ativas" : "Inativas"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-white/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && filtered.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-white/30 gap-3">
|
||||||
|
<Filter className="w-10 h-10 opacity-30" />
|
||||||
|
<p className="text-sm">Nenhuma automação encontrada</p>
|
||||||
|
{search && <p className="text-xs">Tente outro filtro ou busca</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="divide-y divide-white/5">
|
||||||
|
{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] ?? <Zap className="w-3.5 h-3.5" />;
|
||||||
|
const triggerLabel = TRIGGER_LABEL[a.triggerType] ?? a.triggerType;
|
||||||
|
const lastRun = a.lastExecutedAt
|
||||||
|
? new Date(a.lastExecutedAt).toLocaleDateString("pt-BR")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={a.id} className="px-4 py-3 flex items-center gap-3 hover:bg-white/3 transition-colors">
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${a.isActive ? "bg-[#c89b3c]/15 text-[#c89b3c]" : "bg-white/5 text-white/30"}`}>
|
||||||
|
{triggerIcon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium truncate">{a.name}</span>
|
||||||
|
<Badge
|
||||||
|
className={`text-[9px] px-1.5 py-0 shrink-0 ${
|
||||||
|
a.source === "central"
|
||||||
|
? "bg-blue-500/15 text-blue-400 border-blue-500/20"
|
||||||
|
: "bg-purple-500/15 text-purple-400 border-purple-500/20"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{a.source === "central" ? "Central" : "XOS"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5">
|
||||||
|
<span className="text-[11px] text-white/40 flex items-center gap-1">
|
||||||
|
{triggerIcon} {triggerLabel}
|
||||||
|
</span>
|
||||||
|
{a.executionCount > 0 && (
|
||||||
|
<span className="text-[11px] text-white/30">{a.executionCount}x</span>
|
||||||
|
)}
|
||||||
|
{lastRun && (
|
||||||
|
<span className="text-[11px] text-white/30 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" /> {lastRun}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7 p-0 text-white/40 hover:text-white hover:bg-white/10"
|
||||||
|
onClick={() => runMutation.mutate(a.id)}
|
||||||
|
disabled={isRunning || !a.isActive}
|
||||||
|
title="Executar agora"
|
||||||
|
>
|
||||||
|
{isRunning ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
checked={a.isActive}
|
||||||
|
disabled={isToggling}
|
||||||
|
onCheckedChange={(v) => toggleMutation.mutate({ id: a.id, isActive: v })}
|
||||||
|
className="scale-75"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</BrowserFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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<RuntimeResult> {
|
||||||
|
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<string> {
|
||||||
|
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<string, any>): 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<string, any>,
|
||||||
|
userId: string
|
||||||
|
): Promise<RuntimeResult> {
|
||||||
|
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<string> {
|
||||||
|
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<RuntimeResult> {
|
||||||
|
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<RuntimeResult> {
|
||||||
|
return workflowEngine.run(automationId, userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── EventEngine ───────────────────────────────────────────────────────────────
|
||||||
|
// Dispara automações a partir de eventos/webhooks
|
||||||
|
export class EventEngine {
|
||||||
|
private listeners: Map<string, Array<{ automationId: number; userId: string; conditions: any }>> = 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<string, any>): Promise<RuntimeResult[]> {
|
||||||
|
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();
|
||||||
|
|
@ -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<UnifiedAutomation[]> {
|
||||||
|
// 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<FabricRunResult> {
|
||||||
|
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<FabricRunResult> {
|
||||||
|
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<FabricRunResult> {
|
||||||
|
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<string, any> }> = 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<void> {
|
||||||
|
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();
|
||||||
|
|
@ -67,6 +67,7 @@ import { initSocketIO } from "./socket-io";
|
||||||
import { startXosScheduler } from "./xos/scheduler";
|
import { startXosScheduler } from "./xos/scheduler";
|
||||||
import { registerOpenClawRoutes } from "./openclaw/routes";
|
import { registerOpenClawRoutes } from "./openclaw/routes";
|
||||||
import { startPatternDetector } from "./openclaw/pattern-detector";
|
import { startPatternDetector } from "./openclaw/pattern-detector";
|
||||||
|
import { registerAutomationFabricRoutes } from "./automation-fabric/routes";
|
||||||
|
|
||||||
export async function registerRoutes(
|
export async function registerRoutes(
|
||||||
httpServer: Server,
|
httpServer: Server,
|
||||||
|
|
@ -167,6 +168,9 @@ export async function registerRoutes(
|
||||||
registerOpenClawRoutes(app);
|
registerOpenClawRoutes(app);
|
||||||
startPatternDetector();
|
startPatternDetector();
|
||||||
|
|
||||||
|
// Automation Fabric — runtime unificado (Phase 5)
|
||||||
|
registerAutomationFabricRoutes(app);
|
||||||
|
|
||||||
// Central de Protocolos (MCP, A2A, AP2, UCP)
|
// Central de Protocolos (MCP, A2A, AP2, UCP)
|
||||||
app.use("/api", protocolsRoutes);
|
app.use("/api", protocolsRoutes);
|
||||||
registerAgentCard(app); // Agent Card na raiz (/.well-known/agent.json)
|
registerAgentCard(app); // Agent Card na raiz (/.well-known/agent.json)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue