From 1c6c3865e5988ce62c24cfa4998fb4314c1f079a Mon Sep 17 00:00:00 2001 From: Jonas Pacheco Date: Tue, 24 Mar 2026 11:30:51 -0300 Subject: [PATCH] =?UTF-8?q?feat(skills):=20Fase=201=20=E2=80=94=20SkillEng?= =?UTF-8?q?ine=20POO=20+=20ReferenceParser=20+=20API=20REST?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/skills/engine.ts: SkillEngine com herança declarativa (extends), composição via dependências, execução com audit hash SHA-256 e persistência em skill_executions - server/skills/reference-parser.ts: parser e resolver de referências /skill/, /kg/, /var/, /tool/, /data/, /agent/, /file/ com interpolação - server/skills/routes.ts: API REST completa (CRUD + execute + histórico) - server/routes.ts: skills registradas + /api/health global - migrations/0003_arcadia_skills.sql: tabelas arcadia_skills e skill_executions aplicadas em produção - docker-compose.prod.yml: healthcheck via wget /api/health no serviço app Parte do planejamento Arcádia Agentic Suite — Fase 1 (Fundação). Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.prod.yml | 29 +++- migrations/0003_arcadia_skills.sql | 104 ++++++++++++ server/routes.ts | 7 + server/skills/engine.ts | 258 +++++++++++++++++++++++++++++ server/skills/reference-parser.ts | 182 ++++++++++++++++++++ server/skills/routes.ts | 216 ++++++++++++++++++++++++ 6 files changed, 793 insertions(+), 3 deletions(-) create mode 100644 migrations/0003_arcadia_skills.sql create mode 100644 server/skills/engine.ts create mode 100644 server/skills/reference-parser.ts create mode 100644 server/skills/routes.ts diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7c17b0f..8249874 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -77,6 +77,12 @@ services: ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080} ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-} ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-} + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:5000/api/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s ports: - "5000:5000" depends_on: @@ -389,6 +395,24 @@ services: - "traefik.http.middlewares.superset-https-redirect.redirectscheme.permanent=true" profiles: [bi] + # ── Neo4j (Knowledge Graph) ───────────────────────────────────────────────── + # Ativar com: docker compose --profile kg up + neo4j: + image: neo4j:5 + restart: always + environment: + NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-arcadia123} + NEO4J_PLUGINS: '["apoc"]' + volumes: + - neo4j_data:/data + - neo4j_logs:/logs + ports: + - "7474:7474" + - "7687:7687" + networks: + - arcadia-internal + profiles: [kg] + networks: arcadia-internal: driver: bridge @@ -403,9 +427,8 @@ volumes: redis_data: ollama_models: open_webui_data: - - - + neo4j_data: + neo4j_logs: plus_db: plus_storage: superset_home: diff --git a/migrations/0003_arcadia_skills.sql b/migrations/0003_arcadia_skills.sql new file mode 100644 index 0000000..2f387f1 --- /dev/null +++ b/migrations/0003_arcadia_skills.sql @@ -0,0 +1,104 @@ +-- Migration 0003: Arcádia Agentic Suite — Skills POO +-- Criado em: 2026-03-24 | Autor: João +-- Adiciona tabelas arcadia_skills e skill_executions +-- NÃO altera nem remove xos_skill_registry (modelo XOS legado preservado) + +-- ─── Tabela principal de Skills (modelo orientado a objetos) ───────────────── +CREATE TABLE IF NOT EXISTS "arcadia_skills" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identidade + "name" VARCHAR(255) NOT NULL, + "slug" VARCHAR(255) NOT NULL, + "description" TEXT, + "version" VARCHAR(50) NOT NULL DEFAULT '1.0.0', + "icon" VARCHAR(100), + "tags" TEXT[], + + -- Namespace multi-tenant (system > tenant > company > user) + "namespace" VARCHAR(20) NOT NULL DEFAULT 'tenant', + "tenant_id" INTEGER REFERENCES "tenants"("id") ON DELETE CASCADE, + "company_id" INTEGER, + "user_id" VARCHAR REFERENCES "users"("id") ON DELETE SET NULL, + + -- Herança POO: lista de slugs referenciando outras skills + "extends" TEXT[], + -- Interfaces / contratos implementados + "implements" TEXT[], + + -- Encapsulamento + "visibility_execute" VARCHAR(20) DEFAULT 'public', + "visibility_params" VARCHAR(20) DEFAULT 'public', + + -- Composição: dependências como referências /tipo/caminho + "dependencies" TEXT[], + + -- Trigger automático + "trigger_type" VARCHAR(30), + "trigger_config" JSONB, + + -- Corpo da skill (Markdown com blocos /skill/, /kg/, /tool/, etc.) + "body" TEXT, + + -- Schemas de entrada e saída + "parameters_schema" JSONB, + "return_schema" JSONB, + + -- Estado + "status" VARCHAR(20) NOT NULL DEFAULT 'draft', + "is_system" BOOLEAN DEFAULT false, + + -- Autoria + "author" VARCHAR(255), + "created_by" VARCHAR REFERENCES "users"("id") ON DELETE SET NULL, + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Slug único por namespace + tenant +CREATE UNIQUE INDEX IF NOT EXISTS idx_arcadia_skills_slug_tenant + ON arcadia_skills(slug, tenant_id, namespace); + +-- Índices de consulta frequente +CREATE INDEX IF NOT EXISTS idx_arcadia_skills_tenant ON arcadia_skills(tenant_id); +CREATE INDEX IF NOT EXISTS idx_arcadia_skills_namespace ON arcadia_skills(namespace); +CREATE INDEX IF NOT EXISTS idx_arcadia_skills_status ON arcadia_skills(status); +CREATE INDEX IF NOT EXISTS idx_arcadia_skills_created_by ON arcadia_skills(created_by); + +-- ─── Tabela de execuções de skills ────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "skill_executions" ( + "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "skill_id" UUID NOT NULL REFERENCES "arcadia_skills"("id") ON DELETE CASCADE, + + -- Contexto de execução + "tenant_id" INTEGER REFERENCES "tenants"("id"), + "company_id" INTEGER, + "user_id" VARCHAR REFERENCES "users"("id") ON DELETE SET NULL, + + -- Origem + "triggered_by" VARCHAR(30), + "automation_id" INTEGER, + "parent_execution_id" UUID, + + -- Dados + "input_params" JSONB, + "output_result" JSONB, + "resolved_dependencies" JSONB, + + -- Estado + "status" VARCHAR(20) NOT NULL DEFAULT 'pending', + "error_message" TEXT, + "duration_ms" INTEGER, + + -- Imutabilidade / auditoria + "audit_hash" VARCHAR(64), + + "started_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP +); + +-- Índices de consulta frequente +CREATE INDEX IF NOT EXISTS idx_skill_executions_skill_id ON skill_executions(skill_id); +CREATE INDEX IF NOT EXISTS idx_skill_executions_tenant ON skill_executions(tenant_id); +CREATE INDEX IF NOT EXISTS idx_skill_executions_status ON skill_executions(status); +CREATE INDEX IF NOT EXISTS idx_skill_executions_started ON skill_executions(started_at DESC); diff --git a/server/routes.ts b/server/routes.ts index dc96902..40cb5a2 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -14,6 +14,7 @@ import { registerManusRoutes } from "./manus/routes"; import { registerCustomMcpRoutes } from "./mcp/routes"; import { registerAutomationRoutes } from "./automations/routes"; import { registerAutomationEngineRoutes } from "./automations/engine-proxy"; +import { registerSkillRoutes } from "./skills/routes"; import { registerBiRoutes } from "./bi/routes"; import { registerBiEngineRoutes } from "./bi/engine-proxy"; import { registerCommEngineRoutes } from "./communication/proxy"; @@ -68,6 +69,11 @@ export async function registerRoutes( httpServer: Server, app: Express ): Promise { + // Health check global — usado pelo Coolify e Docker healthcheck + app.get("/api/health", (_req, res) => { + res.json({ ok: true, service: "arcadia-suite", ts: new Date().toISOString() }); + }); + // Auth and session setup first setupAuth(app); @@ -93,6 +99,7 @@ export async function registerRoutes( registerCustomMcpRoutes(app); registerAutomationRoutes(app); registerAutomationEngineRoutes(app); + registerSkillRoutes(app); registerBiRoutes(app); app.use("/api/graph", graphRoutes); registerBiEngineRoutes(app); diff --git a/server/skills/engine.ts b/server/skills/engine.ts new file mode 100644 index 0000000..d2a53d4 --- /dev/null +++ b/server/skills/engine.ts @@ -0,0 +1,258 @@ +/** + * SkillEngine — runtime de execução de skills com herança POO. + * + * Suporta: + * - Herança declarativa via `extends` (lista de slugs) + * - Composição via dependências `/skill/...` no body + * - Triggers: manual | schedule | event | webhook | automation | agent | openclaw + * - Persistência de execuções em skill_executions + * - Audit hash (SHA-256) para imutabilidade + */ + +import crypto from "crypto"; +import { db } from "../../db/index"; +import { + arcadiaSkills, + skillExecutions, + type ArcadiaSkill, + type InsertSkillExecution, +} from "@shared/schema"; +import { eq, and, inArray } from "drizzle-orm"; +import { + parseReferences, + ReferenceResolver, + interpolate, + type ExecutionContext, +} from "./reference-parser"; + +export type TriggerSource = + | "manual" + | "schedule" + | "automation" + | "agent" + | "webhook" + | "openclaw" + | "event"; + +export interface ExecuteOptions { + skillId: string; + inputParams?: Record; + context: ExecutionContext; + triggeredBy?: TriggerSource; + automationId?: number; + parentExecutionId?: string; +} + +export interface ExecuteResult { + executionId: string; + skillId: string; + status: "success" | "error"; + output?: unknown; + error?: string; + durationMs: number; + resolvedBody?: string; +} + +// ─── SkillEngine ────────────────────────────────────────────────────────────── + +class SkillEngine { + + // ── Carregar skill com cadeia de herança resolvida ────────────────────────── + + async loadWithInheritance(skillId: string, tenantId?: number): Promise { + const skill = await this.findById(skillId); + if (!skill) throw new Error(`Skill não encontrada: ${skillId}`); + if (!skill.extends || skill.extends.length === 0) return skill; + + // Resolve cadeia de herança: mescla corpo e parâmetros dos pais + const parents = await this.resolveParents(skill.extends, tenantId); + return this.mergeInheritance(skill, parents); + } + + private async findById(id: string): Promise { + const [skill] = await db + .select() + .from(arcadiaSkills) + .where(eq(arcadiaSkills.id, id)) + .limit(1); + return skill; + } + + private async resolveParents( + extendsSlugs: string[], + tenantId?: number + ): Promise { + if (extendsSlugs.length === 0) return []; + + const slugList = extendsSlugs.map((ref) => { + // aceita "/skill/namespace/slug" ou "namespace/slug" ou "slug" + const parts = ref.replace(/^\/skill\//, "").split("/"); + return parts[parts.length - 1]; // pega o slug + }); + + const parents = await db + .select() + .from(arcadiaSkills) + .where(inArray(arcadiaSkills.slug, slugList)); + + return parents; + } + + /** + * Merge simples de herança: + * - body do filho tem prioridade + * - parâmetros dos pais são fundidos (pai → filho sobrescreve) + */ + private mergeInheritance(child: ArcadiaSkill, parents: ArcadiaSkill[]): ArcadiaSkill { + let mergedParams: Record = {}; + + for (const parent of parents) { + if (parent.parametersSchema && typeof parent.parametersSchema === "object") { + mergedParams = { ...mergedParams, ...(parent.parametersSchema as object) }; + } + } + + if (child.parametersSchema && typeof child.parametersSchema === "object") { + mergedParams = { ...mergedParams, ...(child.parametersSchema as object) }; + } + + return { + ...child, + parametersSchema: Object.keys(mergedParams).length > 0 ? mergedParams : child.parametersSchema, + }; + } + + // ── Executar ──────────────────────────────────────────────────────────────── + + async execute(opts: ExecuteOptions): Promise { + const startedAt = Date.now(); + + // Criar registro de execução com status pending + const [execution] = await db + .insert(skillExecutions) + .values({ + skillId: opts.skillId, + tenantId: opts.context.tenantId, + companyId: opts.context.companyId, + userId: opts.context.userId, + triggeredBy: opts.triggeredBy ?? "manual", + automationId: opts.automationId, + parentExecutionId: opts.parentExecutionId, + inputParams: opts.inputParams ?? {}, + status: "running", + } as any) + .returning(); + + try { + const skill = await this.loadWithInheritance(opts.skillId, opts.context.tenantId); + + // Verificar visibilidade + this.assertExecutable(skill, opts.context); + + // Resolver referências no body + const body = skill.body ?? ""; + const refs = parseReferences(body); + const resolver = new ReferenceResolver({ + ...opts.context, + vars: { ...(opts.inputParams ?? {}), ...(opts.context.vars ?? {}) }, + }); + const resolved = await resolver.resolveAll(refs); + const resolvedBody = interpolate(body, resolved); + + const output: Record = { + skill: skill.slug, + resolvedBody, + resolvedDependencies: resolved, + params: opts.inputParams, + }; + + const durationMs = Date.now() - startedAt; + const auditHash = this.buildAuditHash(execution.id, opts.skillId, output); + + await db + .update(skillExecutions) + .set({ + status: "success", + outputResult: output, + resolvedDependencies: resolved, + durationMs, + auditHash, + completedAt: new Date(), + }) + .where(eq(skillExecutions.id, execution.id)); + + return { + executionId: execution.id, + skillId: opts.skillId, + status: "success", + output, + durationMs, + resolvedBody, + }; + } catch (err: any) { + const durationMs = Date.now() - startedAt; + + await db + .update(skillExecutions) + .set({ + status: "error", + errorMessage: err.message, + durationMs, + completedAt: new Date(), + }) + .where(eq(skillExecutions.id, execution.id)); + + return { + executionId: execution.id, + skillId: opts.skillId, + status: "error", + error: err.message, + durationMs, + }; + } + } + + // ── Verificações ──────────────────────────────────────────────────────────── + + private assertExecutable(skill: ArcadiaSkill, ctx: ExecutionContext): void { + if (skill.status !== "active") { + throw new Error(`Skill "${skill.slug}" não está ativa (status: ${skill.status})`); + } + + if (skill.visibilityExecute === "private") { + // Apenas o criador pode executar + if (skill.createdBy && skill.createdBy !== ctx.userId) { + throw new Error(`Skill "${skill.slug}" é privada`); + } + } + } + + // ── Audit ─────────────────────────────────────────────────────────────────── + + private buildAuditHash(executionId: string, skillId: string, output: unknown): string { + const payload = JSON.stringify({ executionId, skillId, output, ts: Date.now() }); + return crypto.createHash("sha256").update(payload).digest("hex"); + } + + // ── Listar execuções ──────────────────────────────────────────────────────── + + async getExecutions(skillId: string, limit = 50) { + return db + .select() + .from(skillExecutions) + .where(eq(skillExecutions.skillId, skillId)) + .orderBy(skillExecutions.startedAt) + .limit(limit); + } + + // ── Health ────────────────────────────────────────────────────────────────── + + async health(): Promise<{ ok: boolean; totalSkills: number }> { + const [{ count }] = await db + .select({ count: db.$count(arcadiaSkills) }) + .from(arcadiaSkills); + return { ok: true, totalSkills: Number(count) }; + } +} + +export const skillEngine = new SkillEngine(); diff --git a/server/skills/reference-parser.ts b/server/skills/reference-parser.ts new file mode 100644 index 0000000..d2152f3 --- /dev/null +++ b/server/skills/reference-parser.ts @@ -0,0 +1,182 @@ +/** + * ReferenceParser — resolve referências /tipo/caminho usadas no body das skills. + * + * Formatos suportados: + * /skill/namespace/slug → executa outra skill + * /kg/node-id → lê nó do Knowledge Graph + * /data/table/filter → query ao banco Arcádia + * /var/nome → variável de contexto da execução + * /tool/nome → chama uma ferramenta registrada + * /agent/nome → delega para um agente + * /file/caminho → lê arquivo do storage + */ + +import { db } from "../../db/index"; +import { arcadiaSkills, graphNodes } from "@shared/schema"; +import { eq, and } from "drizzle-orm"; + +export type ReferenceType = "skill" | "kg" | "data" | "var" | "tool" | "agent" | "file"; + +export interface ParsedReference { + raw: string; // texto original, ex: "/skill/system/base_report" + type: ReferenceType; + path: string[]; // segmentos após o tipo +} + +export interface ResolvedReference { + ref: ParsedReference; + value: unknown; + error?: string; +} + +export interface ExecutionContext { + tenantId?: number; + companyId?: number; + userId?: string; + vars?: Record; + tools?: Record Promise>; +} + +// ─── Parser ────────────────────────────────────────────────────────────────── + +const REF_REGEX = /\/([a-z]+)((?:\/[^\s/,})"']+)+)/g; + +export function parseReferences(text: string): ParsedReference[] { + const refs: ParsedReference[] = []; + const seen = new Set(); + let match: RegExpExecArray | null; + + while ((match = REF_REGEX.exec(text)) !== null) { + const raw = match[0]; + if (seen.has(raw)) continue; + seen.add(raw); + + const type = match[1] as ReferenceType; + const path = match[2].split("/").filter(Boolean); + + if (isValidType(type)) { + refs.push({ raw, type, path }); + } + } + + return refs; +} + +function isValidType(t: string): t is ReferenceType { + return ["skill", "kg", "data", "var", "tool", "agent", "file"].includes(t); +} + +// ─── Resolver ───────────────────────────────────────────────────────────────── + +export class ReferenceResolver { + constructor(private ctx: ExecutionContext) {} + + async resolveAll(refs: ParsedReference[]): Promise> { + const results: Record = {}; + await Promise.all( + refs.map(async (ref) => { + try { + const value = await this.resolve(ref); + results[ref.raw] = { ref, value }; + } catch (err: any) { + results[ref.raw] = { ref, value: null, error: err.message }; + } + }) + ); + return results; + } + + async resolve(ref: ParsedReference): Promise { + switch (ref.type) { + case "skill": + return this.resolveSkill(ref.path); + case "kg": + return this.resolveKg(ref.path); + case "var": + return this.resolveVar(ref.path); + case "tool": + return this.resolveTool(ref.path); + case "data": + return this.resolveData(ref.path); + case "agent": + return { type: "agent_reference", name: ref.path.join("/") }; + case "file": + return { type: "file_reference", path: ref.path.join("/") }; + default: + throw new Error(`Tipo de referência desconhecido: ${(ref as any).type}`); + } + } + + private async resolveSkill(path: string[]): Promise { + // /skill/namespace/slug ou /skill/slug + const [ns, slug] = path.length >= 2 ? [path[0], path[1]] : ["tenant", path[0]]; + + const conditions = [eq(arcadiaSkills.slug, slug), eq(arcadiaSkills.namespace, ns)]; + if (this.ctx.tenantId) { + conditions.push(eq(arcadiaSkills.tenantId, this.ctx.tenantId)); + } + + const [skill] = await db + .select() + .from(arcadiaSkills) + .where(and(...conditions)) + .limit(1); + + if (!skill) throw new Error(`Skill não encontrada: /${ns}/${slug}`); + return { type: "skill_ref", id: skill.id, slug: skill.slug, body: skill.body }; + } + + private async resolveKg(path: string[]): Promise { + const nodeId = parseInt(path[0], 10); + if (isNaN(nodeId)) throw new Error(`ID de nó KG inválido: ${path[0]}`); + + const [node] = await db + .select() + .from(graphNodes) + .where(eq(graphNodes.id, nodeId)) + .limit(1); + + if (!node) throw new Error(`Nó KG não encontrado: ${nodeId}`); + return node; + } + + private resolveVar(path: string[]): unknown { + const name = path.join("."); + const vars = this.ctx.vars ?? {}; + if (!(name in vars)) throw new Error(`Variável não definida: /var/${name}`); + return vars[name]; + } + + private async resolveTool(path: string[]): Promise { + const name = path.join("/"); + const tool = this.ctx.tools?.[name]; + if (!tool) throw new Error(`Ferramenta não registrada: /tool/${name}`); + return { type: "tool_ref", name, callable: true }; + } + + private async resolveData(path: string[]): Promise { + // /data/table/filter — placeholder; implementação por módulo + const [table, ...filterParts] = path; + return { type: "data_ref", table, filter: filterParts.join("/") }; + } +} + +// ─── Interpolação de template ───────────────────────────────────────────────── + +/** + * Substitui referências resolvidas no body da skill. + * Ex: "Resultado de /var/input" → "Resultado de " + */ +export function interpolate( + body: string, + resolved: Record +): string { + let result = body; + for (const [raw, res] of Object.entries(resolved)) { + if (res.error) continue; + const replacement = + typeof res.value === "string" ? res.value : JSON.stringify(res.value); + result = result.replaceAll(raw, replacement); + } + return result; +} diff --git a/server/skills/routes.ts b/server/skills/routes.ts new file mode 100644 index 0000000..4a9aacb --- /dev/null +++ b/server/skills/routes.ts @@ -0,0 +1,216 @@ +import type { Express, Request, Response } from "express"; +import { db } from "../../db/index"; +import { + arcadiaSkills, + skillExecutions, + insertArcadiaSkillSchema, +} from "@shared/schema"; +import { eq, and, desc, ilike, or } from "drizzle-orm"; +import { z } from "zod"; +import { skillEngine } from "./engine"; + +const executeBodySchema = z.object({ + inputParams: z.record(z.unknown()).optional(), + triggeredBy: z + .enum(["manual", "schedule", "automation", "agent", "webhook", "openclaw", "event"]) + .optional() + .default("manual"), + automationId: z.number().int().optional(), + parentExecutionId: z.string().uuid().optional(), +}); + +const listQuerySchema = z.object({ + namespace: z.string().optional(), + status: z.string().optional(), + search: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).optional().default(50), + offset: z.coerce.number().int().min(0).optional().default(0), +}); + +function tenantId(req: Request): number | undefined { + return (req as any).user?.tenantId ?? (req as any).tenantId; +} + +function userId(req: Request): string | undefined { + return (req as any).user?.id ?? (req as any).userId; +} + +export function registerSkillRoutes(app: Express): void { + + // ── Health ──────────────────────────────────────────────────────────────── + + app.get("/api/skills/health", async (_req: Request, res: Response) => { + try { + const status = await skillEngine.health(); + res.json(status); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } + }); + + // ── Listar skills ───────────────────────────────────────────────────────── + + app.get("/api/skills", async (req: Request, res: Response) => { + try { + const q = listQuerySchema.parse(req.query); + const tid = tenantId(req); + + const conditions: any[] = []; + if (tid) conditions.push(eq(arcadiaSkills.tenantId, tid)); + if (q.namespace) conditions.push(eq(arcadiaSkills.namespace, q.namespace)); + if (q.status) conditions.push(eq(arcadiaSkills.status, q.status as any)); + if (q.search) { + conditions.push( + or( + ilike(arcadiaSkills.name, `%${q.search}%`), + ilike(arcadiaSkills.slug, `%${q.search}%`), + ilike(arcadiaSkills.description, `%${q.search}%`) + ) + ); + } + + const skills = await db + .select() + .from(arcadiaSkills) + .where(conditions.length ? and(...conditions) : undefined) + .orderBy(desc(arcadiaSkills.createdAt)) + .limit(q.limit) + .offset(q.offset); + + res.json({ skills, total: skills.length }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Criar skill ─────────────────────────────────────────────────────────── + + app.post("/api/skills", async (req: Request, res: Response) => { + try { + const tid = tenantId(req); + const uid = userId(req); + const data = insertArcadiaSkillSchema.parse({ + ...req.body, + tenantId: tid, + createdBy: uid, + }); + + const [skill] = await db.insert(arcadiaSkills).values(data).returning(); + res.status(201).json(skill); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Buscar skill por ID ─────────────────────────────────────────────────── + + app.get("/api/skills/:id", async (req: Request, res: Response) => { + try { + const [skill] = await db + .select() + .from(arcadiaSkills) + .where(eq(arcadiaSkills.id, req.params.id)) + .limit(1); + + if (!skill) return res.status(404).json({ error: "Skill não encontrada" }); + res.json(skill); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Atualizar skill ─────────────────────────────────────────────────────── + + app.put("/api/skills/:id", async (req: Request, res: Response) => { + try { + const [existing] = await db + .select() + .from(arcadiaSkills) + .where(eq(arcadiaSkills.id, req.params.id)) + .limit(1); + + if (!existing) return res.status(404).json({ error: "Skill não encontrada" }); + + const [updated] = await db + .update(arcadiaSkills) + .set({ ...req.body, updatedAt: new Date() }) + .where(eq(arcadiaSkills.id, req.params.id)) + .returning(); + + res.json(updated); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Deletar skill ───────────────────────────────────────────────────────── + + app.delete("/api/skills/:id", async (req: Request, res: Response) => { + try { + const result = await db + .delete(arcadiaSkills) + .where(eq(arcadiaSkills.id, req.params.id)); + + if ((result.rowCount ?? 0) === 0) { + return res.status(404).json({ error: "Skill não encontrada" }); + } + res.json({ deleted: true }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Executar skill ──────────────────────────────────────────────────────── + + app.post("/api/skills/:id/execute", async (req: Request, res: Response) => { + try { + const body = executeBodySchema.parse(req.body); + + const result = await skillEngine.execute({ + skillId: req.params.id, + inputParams: body.inputParams, + context: { + tenantId: tenantId(req), + userId: userId(req), + }, + triggeredBy: body.triggeredBy, + automationId: body.automationId, + parentExecutionId: body.parentExecutionId, + }); + + const statusCode = result.status === "success" ? 200 : 500; + res.status(statusCode).json(result); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Histórico de execuções ──────────────────────────────────────────────── + + app.get("/api/skills/:id/executions", async (req: Request, res: Response) => { + try { + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + const executions = await skillEngine.getExecutions(req.params.id, limit); + res.json({ executions }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Execução por ID ─────────────────────────────────────────────────────── + + app.get("/api/skills/executions/:executionId", async (req: Request, res: Response) => { + try { + const [execution] = await db + .select() + .from(skillExecutions) + .where(eq(skillExecutions.id, req.params.executionId)) + .limit(1); + + if (!execution) return res.status(404).json({ error: "Execução não encontrada" }); + res.json(execution); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); +}