feat(skills): Fase 1 — SkillEngine POO + ReferenceParser + API REST

- 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 <noreply@anthropic.com>
This commit is contained in:
Jonas Pacheco 2026-03-24 11:30:51 -03:00
parent 5b35aa49a2
commit 1c6c3865e5
6 changed files with 793 additions and 3 deletions

View File

@ -77,6 +77,12 @@ services:
ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080} ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080}
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-} ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-} 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: ports:
- "5000:5000" - "5000:5000"
depends_on: depends_on:
@ -389,6 +395,24 @@ services:
- "traefik.http.middlewares.superset-https-redirect.redirectscheme.permanent=true" - "traefik.http.middlewares.superset-https-redirect.redirectscheme.permanent=true"
profiles: [bi] 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: networks:
arcadia-internal: arcadia-internal:
driver: bridge driver: bridge
@ -403,9 +427,8 @@ volumes:
redis_data: redis_data:
ollama_models: ollama_models:
open_webui_data: open_webui_data:
neo4j_data:
neo4j_logs:
plus_db: plus_db:
plus_storage: plus_storage:
superset_home: superset_home:

View File

@ -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);

View File

@ -14,6 +14,7 @@ import { registerManusRoutes } from "./manus/routes";
import { registerCustomMcpRoutes } from "./mcp/routes"; import { registerCustomMcpRoutes } from "./mcp/routes";
import { registerAutomationRoutes } from "./automations/routes"; import { registerAutomationRoutes } from "./automations/routes";
import { registerAutomationEngineRoutes } from "./automations/engine-proxy"; import { registerAutomationEngineRoutes } from "./automations/engine-proxy";
import { registerSkillRoutes } from "./skills/routes";
import { registerBiRoutes } from "./bi/routes"; import { registerBiRoutes } from "./bi/routes";
import { registerBiEngineRoutes } from "./bi/engine-proxy"; import { registerBiEngineRoutes } from "./bi/engine-proxy";
import { registerCommEngineRoutes } from "./communication/proxy"; import { registerCommEngineRoutes } from "./communication/proxy";
@ -68,6 +69,11 @@ export async function registerRoutes(
httpServer: Server, httpServer: Server,
app: Express app: Express
): Promise<Server> { ): Promise<Server> {
// 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 // Auth and session setup first
setupAuth(app); setupAuth(app);
@ -93,6 +99,7 @@ export async function registerRoutes(
registerCustomMcpRoutes(app); registerCustomMcpRoutes(app);
registerAutomationRoutes(app); registerAutomationRoutes(app);
registerAutomationEngineRoutes(app); registerAutomationEngineRoutes(app);
registerSkillRoutes(app);
registerBiRoutes(app); registerBiRoutes(app);
app.use("/api/graph", graphRoutes); app.use("/api/graph", graphRoutes);
registerBiEngineRoutes(app); registerBiEngineRoutes(app);

258
server/skills/engine.ts Normal file
View File

@ -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<string, unknown>;
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<ArcadiaSkill> {
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<ArcadiaSkill | undefined> {
const [skill] = await db
.select()
.from(arcadiaSkills)
.where(eq(arcadiaSkills.id, id))
.limit(1);
return skill;
}
private async resolveParents(
extendsSlugs: string[],
tenantId?: number
): Promise<ArcadiaSkill[]> {
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<string, unknown> = {};
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<ExecuteResult> {
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<string, unknown> = {
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();

View File

@ -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<string, unknown>;
tools?: Record<string, (...args: unknown[]) => Promise<unknown>>;
}
// ─── Parser ──────────────────────────────────────────────────────────────────
const REF_REGEX = /\/([a-z]+)((?:\/[^\s/,})"']+)+)/g;
export function parseReferences(text: string): ParsedReference[] {
const refs: ParsedReference[] = [];
const seen = new Set<string>();
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<Record<string, ResolvedReference>> {
const results: Record<string, ResolvedReference> = {};
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<unknown> {
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<unknown> {
// /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<unknown> {
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<unknown> {
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<unknown> {
// /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 <valor>"
*/
export function interpolate(
body: string,
resolved: Record<string, ResolvedReference>
): 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;
}

216
server/skills/routes.ts Normal file
View File

@ -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 });
}
});
}