feat(workflows): stamp runs with revision and improve history preview
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function up(knex) {
|
||||
await knex.schema.alterTable("workflow_runs", (t) => {
|
||||
t.integer("workflow_revision");
|
||||
});
|
||||
await knex.schema.raw(
|
||||
"CREATE INDEX workflow_runs_workflow_revision_idx ON workflow_runs (workflow, workflow_revision)",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("knex").Knex} knex
|
||||
*/
|
||||
export async function down(knex) {
|
||||
await knex.schema.raw("DROP INDEX IF EXISTS workflow_runs_workflow_revision_idx");
|
||||
await knex.schema.alterTable("workflow_runs", (t) => {
|
||||
t.dropColumn("workflow_revision");
|
||||
});
|
||||
}
|
||||
@@ -65,6 +65,7 @@ function nowIso() {
|
||||
* input?: unknown,
|
||||
* parentRunId?: string | null,
|
||||
* status?: "queued" | "running",
|
||||
* workflowRevision?: number | null,
|
||||
* }} opts
|
||||
*/
|
||||
export async function startRun({
|
||||
@@ -75,6 +76,7 @@ export async function startRun({
|
||||
input = null,
|
||||
parentRunId = null,
|
||||
status = "queued",
|
||||
workflowRevision = null,
|
||||
}) {
|
||||
const id = randomUUID();
|
||||
const now = nowIso();
|
||||
@@ -91,6 +93,7 @@ export async function startRun({
|
||||
queued_at: isQueued ? now : null,
|
||||
input: serialize(input),
|
||||
parent_run_id: parentRunId ?? null,
|
||||
workflow_revision: workflowRevision ?? null,
|
||||
});
|
||||
return { id, started_at: now, queued_at: isQueued ? now : null };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
listRevisions,
|
||||
getLatestRevision,
|
||||
deleteRevisionHistory,
|
||||
ensureInitialRevision,
|
||||
} from "../workflow-history.js";
|
||||
import {
|
||||
moveWorkflowToTrash,
|
||||
@@ -89,6 +90,47 @@ const rev2 = await recordRevision({
|
||||
assert.equal(rev2.revision, 2);
|
||||
assert.equal((await listRevisions(workflowId)).length, 2);
|
||||
|
||||
const yamlDisabled = `${yamlV2}\nenabled: false\n`;
|
||||
assert.equal(
|
||||
workflowContentSha(yamlV2),
|
||||
workflowContentSha(yamlDisabled),
|
||||
"enabled-only change should not change content SHA",
|
||||
);
|
||||
const revDisable = await recordRevision({
|
||||
workflowId,
|
||||
owner,
|
||||
file,
|
||||
content: yamlDisabled,
|
||||
reason: "disable",
|
||||
});
|
||||
assert.equal(revDisable.skipped, true, "enable/disable should not create a revision");
|
||||
assert.equal((await listRevisions(workflowId)).length, 2);
|
||||
|
||||
const { startRun, getRun } = await import("../store.js");
|
||||
const latest = await getLatestRevision(workflowId);
|
||||
assert.equal(latest?.revision, 2);
|
||||
const run = await startRun({
|
||||
owner,
|
||||
workflow: `${owner}/${file}`,
|
||||
workflowName: "smoke test v2",
|
||||
trigger: { type: "manual", detail: "smoke" },
|
||||
input: null,
|
||||
workflowRevision: latest?.revision ?? null,
|
||||
});
|
||||
const loaded = await getRun(run.id);
|
||||
assert.equal(loaded.workflow_revision, 2);
|
||||
await db("workflow_runs").where({ id: run.id }).del();
|
||||
|
||||
await deleteRevisionHistory(workflowId);
|
||||
assert.equal(await getLatestRevision(workflowId), null);
|
||||
const seeded = await ensureInitialRevision({ owner, file });
|
||||
assert.ok(seeded);
|
||||
assert.equal(seeded.revision, 1);
|
||||
assert.equal(seeded.seeded, true);
|
||||
const again = await ensureInitialRevision({ owner, file });
|
||||
assert.equal(again?.revision, 1);
|
||||
assert.equal(again?.seeded, false);
|
||||
|
||||
const warnings = collectWorkflowWarnings(
|
||||
`name: bad\nscripts:\n - unknown-script-xyz\n`,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { db } from "./db.js";
|
||||
import { workflowContentSha } from "./workflow-normalize.js";
|
||||
import * as fsStore from "./fs-store.js";
|
||||
import { workflowContentSha, workflowIdFromFile } from "./workflow-normalize.js";
|
||||
|
||||
const MAX_REVISIONS = 50;
|
||||
|
||||
@@ -70,6 +71,50 @@ export async function getRevision(workflowId, revision) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a workflow has at least revision #1 (seed from disk when history is empty).
|
||||
* @param {{ owner: string, file: string }} opts
|
||||
* @returns {Promise<{ revision: number, id: string, content_sha: string, created_at: string, seeded: boolean } | null>}
|
||||
*/
|
||||
export async function ensureInitialRevision(opts) {
|
||||
const workflowId = workflowIdFromFile(opts.file);
|
||||
const latest = await getLatestRevision(workflowId);
|
||||
if (latest) {
|
||||
return {
|
||||
revision: latest.revision,
|
||||
id: latest.id,
|
||||
content_sha: latest.content_sha,
|
||||
created_at: latest.created_at,
|
||||
seeded: false,
|
||||
};
|
||||
}
|
||||
|
||||
const content = fsStore.readWorkflowYaml(opts.owner, opts.file);
|
||||
if (content == null) return null;
|
||||
|
||||
const recorded = await recordRevision({
|
||||
workflowId,
|
||||
owner: opts.owner,
|
||||
file: opts.file,
|
||||
content,
|
||||
reason: "seed",
|
||||
force: true,
|
||||
});
|
||||
|
||||
if (recorded.revision == null || recorded.id == null) return null;
|
||||
|
||||
const row = await getLatestRevision(workflowId);
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
revision: row.revision,
|
||||
id: row.id,
|
||||
content_sha: row.content_sha,
|
||||
created_at: row.created_at,
|
||||
seeded: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a revision when content changed (SHA dedup skips identical saves).
|
||||
* @param {{
|
||||
|
||||
@@ -24,12 +24,22 @@ export function parseWorkflowObject(content) {
|
||||
return yaml.parse(content) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop `enabled` before hashing so enable/disable does not create revision points.
|
||||
* @param {unknown} parsed
|
||||
*/
|
||||
function stripEnabledForHash(parsed) {
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
|
||||
const { enabled, ...rest } = parsed;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA256 of normalized workflow content (YAML → object → canonical JSON).
|
||||
* @param {string} content
|
||||
*/
|
||||
export function workflowContentSha(content) {
|
||||
const parsed = parseWorkflowObject(content);
|
||||
const parsed = stripEnabledForHash(parseWorkflowObject(content));
|
||||
const canonical = canonicalize(parsed);
|
||||
const json = JSON.stringify(canonical);
|
||||
return createHash("sha256").update(json, "utf8").digest("hex");
|
||||
|
||||
Reference in New Issue
Block a user