Files
jerapah-flow/packages/server/workflow-history.js
T
Cursor Agentandnsrb 7d51ff433f feat(workflows): add revision history, trash, UUID naming, and backup
- Store up to 50 revisions per workflow in SQLite with normalized SHA dedup
- Soft-delete workflows to trash (7-day retention) with restore and permanent purge
- Assign UUID filenames for new and duplicated workflows; show name + file in UI
- Warn on invalid YAML, unknown scripts, and plaintext secrets with save-anyway option
- Add workflow backup zip (workflows + plugins) and merge/replace restore
- Trash page, history panel on editor, and smoke test

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-20 02:14:50 +00:00

129 lines
3.0 KiB
JavaScript

import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { workflowContentSha } from "./workflow-normalize.js";
const MAX_REVISIONS = 50;
function nowIso() {
return new Date().toISOString();
}
/**
* @param {string | null} meta
*/
function parseMeta(meta) {
if (!meta) return null;
try {
return JSON.parse(meta);
} catch {
return null;
}
}
/**
* @param {string} workflowId
*/
export async function getLatestRevision(workflowId) {
const row = await db("workflow_revisions")
.where({ workflow_id: workflowId })
.orderBy("revision", "desc")
.first();
if (!row) return null;
return {
...row,
meta: parseMeta(row.meta),
};
}
/**
* @param {string} workflowId
*/
export async function listRevisions(workflowId) {
const rows = await db("workflow_revisions")
.where({ workflow_id: workflowId })
.orderBy("revision", "desc");
return rows.map((row) => ({
id: row.id,
workflow_id: row.workflow_id,
owner: row.owner,
file: row.file,
revision: row.revision,
content_sha: row.content_sha,
reason: row.reason ?? null,
meta: parseMeta(row.meta),
created_at: row.created_at,
}));
}
/**
* @param {string} workflowId
* @param {number} revision
*/
export async function getRevision(workflowId, revision) {
const row = await db("workflow_revisions")
.where({ workflow_id: workflowId, revision })
.first();
if (!row) return null;
return {
...row,
meta: parseMeta(row.meta),
};
}
/**
* Insert a revision when content changed (SHA dedup skips identical saves).
* @param {{
* workflowId: string,
* owner: string,
* file: string,
* content: string,
* reason?: string | null,
* meta?: Record<string, unknown> | null,
* force?: boolean,
* }} opts
* @returns {Promise<{ skipped: boolean, revision: number | null, id: string | null }>}
*/
export async function recordRevision(opts) {
const sha = workflowContentSha(opts.content);
const latest = await getLatestRevision(opts.workflowId);
if (!opts.force && latest && latest.content_sha === sha) {
return { skipped: true, revision: latest.revision, id: latest.id };
}
const nextRevision = latest ? latest.revision + 1 : 1;
const id = randomUUID();
const created_at = nowIso();
await db("workflow_revisions").insert({
id,
workflow_id: opts.workflowId,
owner: opts.owner,
file: opts.file,
revision: nextRevision,
content_sha: sha,
content: opts.content,
reason: opts.reason ?? null,
meta: opts.meta ? JSON.stringify(opts.meta) : null,
created_at,
});
const overflow = await db("workflow_revisions")
.where({ workflow_id: opts.workflowId })
.orderBy("revision", "desc")
.offset(MAX_REVISIONS)
.pluck("id");
if (overflow.length) {
await db("workflow_revisions").whereIn("id", overflow).del();
}
return { skipped: false, revision: nextRevision, id };
}
/**
* @param {string} workflowId
*/
export async function deleteRevisionHistory(workflowId) {
return db("workflow_revisions").where({ workflow_id: workflowId }).del();
}