feat(workflows): stamp runs with revision and improve history preview

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 22:31:18 +07:00
co-authored by Cursor
parent f68376587e
commit 84f29057df
10 changed files with 521 additions and 63 deletions
@@ -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");
});
}
+3
View File
@@ -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`,
);
+46 -1
View File
@@ -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 {{
+11 -1
View File
@@ -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");