feat(02-04): implementar versionamento Git-like de skills com rollback, fork e diff
This commit is contained in:
parent
9232666b8c
commit
7528d4c36e
|
|
@ -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();
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SkillVersion> {
|
||||
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<SkillVersion[]> {
|
||||
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<SkillVersion | null> {
|
||||
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<Skill> {
|
||||
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<Skill> {
|
||||
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<any> {
|
||||
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<string, any> {
|
||||
const diff: Record<string, any> = {};
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue