diff --git a/migrations/add_skill_versions_table.sql b/migrations/add_skill_versions_table.sql new file mode 100644 index 0000000..0f2f454 --- /dev/null +++ b/migrations/add_skill_versions_table.sql @@ -0,0 +1,36 @@ +-- Migration: Add skill_versions table for Git-like versioning +-- Phase 2.4: Versionamento Git-like de skills + +CREATE TABLE IF NOT EXISTS skill_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + skill_id UUID NOT NULL REFERENCES arcadia_skills(id) ON DELETE CASCADE, + sha VARCHAR(64) NOT NULL, + message TEXT NOT NULL, + author VARCHAR(255) NOT NULL, + content JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- Índices + UNIQUE(skill_id, sha), + INDEX skill_versions_skill_id (skill_id), + INDEX skill_versions_created_at (created_at) +); + +-- Trigger para criar versão inicial ao criar skill (opcional) +-- CREATE OR REPLACE FUNCTION create_initial_skill_version() +-- RETURNS TRIGGER AS $$ +-- BEGIN +-- INSERT INTO skill_versions (skill_id, sha, message, author, content) +-- VALUES ( +-- NEW.id, +-- encode(digest(NEW.id || NEW.content::text, 'sha256'), 'hex'), +-- 'Initial version', +-- 'system', +-- NEW.content +-- ); +-- RETURN NEW; +-- END; +-- $$ LANGUAGE plpgsql; +-- +-- CREATE TRIGGER skill_initial_version AFTER INSERT ON arcadia_skills +-- FOR EACH ROW EXECUTE FUNCTION create_initial_skill_version(); diff --git a/server/skills/routes.ts b/server/skills/routes.ts index d52f8d9..5d70510 100644 --- a/server/skills/routes.ts +++ b/server/skills/routes.ts @@ -8,6 +8,9 @@ import { import { eq, and, desc, ilike, or, ne } from "drizzle-orm"; import { z } from "zod"; import { skillEngine } from "./engine"; +import { VersionManager } from "./versioning"; + +const versionManager = new VersionManager(); const executeBodySchema = z.object({ inputParams: z.record(z.unknown()).optional(), @@ -307,4 +310,115 @@ export function registerSkillRoutes(app: Express): void { res.status(400).json({ error: err.message }); } }); + + // ── Versionamento: listar versões ────────────────────────────────────────── + + app.get("/api/skills/:id/versions", async (req: Request, res: Response) => { + try { + const { id } = req.params; + const limit = parseInt(req.query.limit as string) || 20; + const offset = parseInt(req.query.offset as string) || 0; + + const versions = await versionManager.listVersions(id, limit, offset); + res.json({ versions }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Versionamento: criar versão ──────────────────────────────────────────── + + app.post("/api/skills/:id/versions", async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { message } = req.body; + const uid = userId(req); + + if (!message) { + return res.status(400).json({ error: "message required" }); + } + + const skill = await db + .select() + .from(arcadiaSkills) + .where(eq(arcadiaSkills.id, id)) + .executeTakeFirst(); + + if (!skill) { + return res.status(404).json({ error: "Skill not found" }); + } + + const version = await versionManager.createVersion( + id, + message, + uid || "system", + skill.content + ); + + res.json({ version }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Versionamento: rollback ──────────────────────────────────────────────── + + app.post("/api/skills/:id/rollback", async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { sha } = req.body; + const uid = userId(req); + + if (!sha) { + return res.status(400).json({ error: "sha required" }); + } + + const skill = await versionManager.rollbackToVersion(id, sha, uid || "system"); + res.json({ skill, message: `Rolled back to ${sha.slice(0, 7)}` }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Versionamento: fork ──────────────────────────────────────────────────── + + app.post("/api/skills/:id/fork", async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { newSlug } = req.body; + const uid = userId(req); + + if (!newSlug) { + return res.status(400).json({ error: "newSlug required" }); + } + + const skill = await versionManager.fork(id, newSlug, uid || "system"); + res.json({ skill, message: `Forked to ${newSlug}` }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); + + // ── Versionamento: diff ──────────────────────────────────────────────────── + + app.get("/api/skills/:id/diff", async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { from, to } = req.query; + + if (!from || !to) { + return res.status(400).json({ error: "from and to shas required" }); + } + + const diff = await versionManager.diffVersions( + id, + from as string, + to as string + ); + + res.json({ diff }); + } catch (err: any) { + res.status(400).json({ error: err.message }); + } + }); } diff --git a/server/skills/versioning.ts b/server/skills/versioning.ts new file mode 100644 index 0000000..64f1657 --- /dev/null +++ b/server/skills/versioning.ts @@ -0,0 +1,156 @@ +import crypto from 'crypto'; +import { db } from '@/lib/db'; +import { Skill } from './engine'; + +export interface SkillVersion { + id: string; + skillId: string; + sha: string; + message: string; + author: string; + timestamp: Date; + content: any; +} + +export class VersionManager { + async createVersion(skillId: string, message: string, author: string, content: any): Promise { + const sha = this.generateSha(skillId, content); + + const version = await db.insertInto('skill_versions').values({ + skill_id: skillId, + sha, + message, + author, + content: JSON.stringify(content), + created_at: new Date() + }).returning('*').executeTakeFirst(); + + return this.mapVersion(version); + } + + async listVersions(skillId: string, limit = 20, offset = 0): Promise { + const versions = await db + .selectFrom('skill_versions') + .where('skill_id', '=', skillId) + .orderBy('created_at', 'desc') + .limit(limit) + .offset(offset) + .selectAll() + .execute(); + + return versions.map(v => this.mapVersion(v)); + } + + async getVersion(skillId: string, sha: string): Promise { + const version = await db + .selectFrom('skill_versions') + .where('skill_id', '=', skillId) + .where('sha', '=', sha) + .selectAll() + .executeTakeFirst(); + + return version ? this.mapVersion(version) : null; + } + + async rollbackToVersion(skillId: string, versionSha: string, author: string): Promise { + const version = await this.getVersion(skillId, versionSha); + if (!version) throw new Error(`Version ${versionSha} not found`); + + const content = JSON.parse(version.content); + + // Update main skill table + await db + .updateTable('arcadia_skills') + .set({ + content: JSON.stringify(content), + updated_at: new Date() + }) + .where('id', '=', skillId) + .execute(); + + // Create rollback version + await this.createVersion(skillId, `Rollback to ${versionSha.slice(0, 7)}`, author, content); + + return { id: skillId, ...content } as any; + } + + async fork(skillId: string, newSlug: string, author: string): Promise { + const original = await db + .selectFrom('arcadia_skills') + .where('id', '=', skillId) + .selectAll() + .executeTakeFirst(); + + if (!original) throw new Error(`Skill ${skillId} not found`); + + const newId = crypto.randomUUID(); + const content = JSON.parse(original.content); + + const forked = await db + .insertInto('arcadia_skills') + .values({ + id: newId, + slug: newSlug, + name: `${original.name} (fork)`, + description: `Fork of ${original.slug}`, + type: original.type, + content: JSON.stringify(content), + tenant_id: original.tenant_id, + status: 'draft', + created_at: new Date() + }) + .returning('*') + .executeTakeFirst(); + + // Create initial version + await this.createVersion(newId, `Forked from ${original.slug}`, author, content); + + return { id: newId, ...content } as any; + } + + async diffVersions(skillId: string, sha1: string, sha2: string): Promise { + const v1 = await this.getVersion(skillId, sha1); + const v2 = await this.getVersion(skillId, sha2); + + if (!v1 || !v2) throw new Error('One or both versions not found'); + + const content1 = JSON.parse(v1.content); + const content2 = JSON.parse(v2.content); + + return { + from: { sha: sha1, timestamp: v1.timestamp }, + to: { sha: sha2, timestamp: v2.timestamp }, + changes: this.computeDiff(content1, content2) + }; + } + + private generateSha(skillId: string, content: any): string { + const str = `${skillId}${JSON.stringify(content)}${Date.now()}`; + return crypto.createHash('sha256').update(str).digest('hex'); + } + + private computeDiff(obj1: any, obj2: any): Record { + const diff: Record = {}; + + const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]); + for (const key of allKeys) { + if (JSON.stringify(obj1[key]) !== JSON.stringify(obj2[key])) { + diff[key] = { before: obj1[key], after: obj2[key] }; + } + } + + return diff; + } + + private mapVersion(row: any): SkillVersion { + return { + id: row.id, + skillId: row.skill_id, + sha: row.sha, + message: row.message, + author: row.author, + timestamp: row.created_at, + content: JSON.parse(row.content) + }; + } +}