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:
parent
5b35aa49a2
commit
1c6c3865e5
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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<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
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue