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_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:
|
||||||
|
|
|
||||||
|
|
@ -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 { 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);
|
||||||
|
|
|
||||||
|
|
@ -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