Files
jerapah-flow/packages/server/workflow-trash.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

184 lines
4.8 KiB
JavaScript

import fs from "fs";
import path from "path";
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { deleteRevisionHistory } from "./workflow-history.js";
import { DATA_DIR, WORKFLOWS_DIR } from "./paths.js";
import * as fsStore from "./fs-store.js";
export const TRASH_WORKFLOWS_DIR = path.join(DATA_DIR, "trash", "workflows");
export const TRASH_RETENTION_DAYS = 7;
function nowIso() {
return new Date().toISOString();
}
function trashFilePath(owner, file) {
return path.join(TRASH_WORKFLOWS_DIR, owner, file);
}
/**
* @param {string} deletedAtIso
*/
export function trashAgeMs(deletedAtIso) {
return Date.now() - Date.parse(deletedAtIso);
}
/**
* @param {string} deletedAtIso
*/
export function trashDaysRemaining(deletedAtIso) {
const purgeAt =
Date.parse(deletedAtIso) + TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000;
return Math.max(0, Math.ceil((purgeAt - Date.now()) / (24 * 60 * 60 * 1000)));
}
function rowToItem(row) {
return {
id: row.id,
workflow_id: row.workflow_id,
owner: row.owner,
file: row.file,
name: row.name ?? null,
deleted_at: row.deleted_at,
trash_path: row.trash_path,
age_ms: trashAgeMs(row.deleted_at),
days_until_purge: trashDaysRemaining(row.deleted_at),
};
}
export async function listTrash() {
const rows = await db("workflow_trash").orderBy("deleted_at", "desc");
return rows.map(rowToItem);
}
export async function getTrashItem(id) {
const row = await db("workflow_trash").where({ id }).first();
return row ? rowToItem(row) : null;
}
export async function isInTrash(owner, file) {
const row = await db("workflow_trash").where({ owner, file }).first();
return Boolean(row);
}
/**
* Soft-delete: move YAML to trash dir, unregister, keep revision history.
* @param {{
* workflowId: string,
* owner: string,
* file: string,
* name?: string | null,
* }} opts
*/
export async function moveWorkflowToTrash(opts) {
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
if (!fs.existsSync(sourcePath)) {
const err = new Error("workflow not found");
err.statusCode = 404;
throw err;
}
const trashPath = trashFilePath(opts.owner, opts.file);
fs.mkdirSync(path.dirname(trashPath), { recursive: true });
fs.renameSync(sourcePath, trashPath);
const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file);
fsStore.writeRegisters(opts.owner, registered);
const id = randomUUID();
const deleted_at = nowIso();
await db("workflow_trash").insert({
id,
workflow_id: opts.workflowId,
owner: opts.owner,
file: opts.file,
name: opts.name ?? null,
deleted_at,
trash_path: trashPath,
});
return rowToItem(await db("workflow_trash").where({ id }).first());
}
/**
* Restore workflow from trash.
* @param {string} trashId
*/
export async function restoreFromTrash(trashId) {
const row = await db("workflow_trash").where({ id: trashId }).first();
if (!row) {
const err = new Error("trash item not found");
err.statusCode = 404;
throw err;
}
const destPath = path.join(WORKFLOWS_DIR, row.owner, row.file);
if (fs.existsSync(destPath)) {
const err = new Error("workflow file already exists");
err.statusCode = 409;
throw err;
}
if (!fs.existsSync(row.trash_path)) {
const err = new Error("trash file missing on disk");
err.statusCode = 410;
throw err;
}
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.renameSync(row.trash_path, destPath);
const registered = fsStore.readRegisters(row.owner);
if (!registered.includes(row.file)) {
registered.push(row.file);
fsStore.writeRegisters(row.owner, registered);
}
await db("workflow_trash").where({ id: trashId }).del();
return {
owner: row.owner,
file: row.file,
workflow_id: row.workflow_id,
content: fs.readFileSync(destPath, "utf8"),
};
}
/**
* Permanently delete a trash item and its revision history.
* @param {string} trashId
*/
export async function purgeTrashItem(trashId) {
const row = await db("workflow_trash").where({ id: trashId }).first();
if (!row) {
const err = new Error("trash item not found");
err.statusCode = 404;
throw err;
}
if (fs.existsSync(row.trash_path)) {
fs.unlinkSync(row.trash_path);
}
await deleteRevisionHistory(row.workflow_id);
await db("workflow_trash").where({ id: trashId }).del();
return { ok: true };
}
/**
* Auto-purge trash older than retention window.
* @returns {Promise<number>}
*/
export async function purgeExpiredTrash() {
const cutoff = new Date(
Date.now() - TRASH_RETENTION_DAYS * 24 * 60 * 60 * 1000,
).toISOString();
const rows = await db("workflow_trash")
.where("deleted_at", "<", cutoff)
.select("id");
for (const row of rows) {
await purgeTrashItem(row.id);
}
return rows.length;
}