From 84f29057dfdfcc34b9f9bf7c25da6589affcd208 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Thu, 20 Aug 2026 22:31:18 +0700 Subject: [PATCH] feat(workflows): stamp runs with revision and improve history preview Co-authored-by: Cursor --- .../20260820050000_workflow_run_revision.js | 21 ++ packages/server/store.js | 3 + .../server/test/workflow-history-smoke.js | 42 ++++ packages/server/workflow-history.js | 47 +++- packages/server/workflow-normalize.js | 12 +- .../workflow/WorkflowHistoryPanel.jsx | 113 +++++++--- .../workflow/WorkflowRevisionBanner.jsx | 129 +++++++++++ packages/web/src/pages/EventDetailPage.jsx | 8 +- packages/web/src/pages/EventsPage.jsx | 4 + packages/web/src/pages/WorkflowEditPage.jsx | 205 +++++++++++++++--- 10 files changed, 521 insertions(+), 63 deletions(-) create mode 100644 packages/server/migrations/20260820050000_workflow_run_revision.js create mode 100644 packages/web/src/components/workflow/WorkflowRevisionBanner.jsx diff --git a/packages/server/migrations/20260820050000_workflow_run_revision.js b/packages/server/migrations/20260820050000_workflow_run_revision.js new file mode 100644 index 0000000..7312b3d --- /dev/null +++ b/packages/server/migrations/20260820050000_workflow_run_revision.js @@ -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"); + }); +} diff --git a/packages/server/store.js b/packages/server/store.js index b348d29..c680529 100644 --- a/packages/server/store.js +++ b/packages/server/store.js @@ -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 }; } diff --git a/packages/server/test/workflow-history-smoke.js b/packages/server/test/workflow-history-smoke.js index 00978b2..3694988 100644 --- a/packages/server/test/workflow-history-smoke.js +++ b/packages/server/test/workflow-history-smoke.js @@ -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`, ); diff --git a/packages/server/workflow-history.js b/packages/server/workflow-history.js index 9063aa1..5a04eff 100644 --- a/packages/server/workflow-history.js +++ b/packages/server/workflow-history.js @@ -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 {{ diff --git a/packages/server/workflow-normalize.js b/packages/server/workflow-normalize.js index a1c8ef5..6407005 100644 --- a/packages/server/workflow-normalize.js +++ b/packages/server/workflow-normalize.js @@ -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"); diff --git a/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx b/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx index fcdae63..e108454 100644 --- a/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx +++ b/packages/web/src/components/workflow/WorkflowHistoryPanel.jsx @@ -12,6 +12,7 @@ import { isSaveWarningsError, saveWarningsFromError, } from "./SaveWorkflowWarningsDialog.jsx"; +import { ConfirmDialog } from "./WorkflowRevisionBanner.jsx"; function reasonLabel(reason, meta) { if (reason === "duplicated" && meta?.from) return `duplicated from ${meta.from}`; @@ -22,13 +23,23 @@ function reasonLabel(reason, meta) { return reason ?? "save"; } -export function WorkflowHistoryPanel({ owner, file, onReverted }) { +export function WorkflowHistoryPanel({ + owner, + file, + previewRevision, + onSelectRevision, + onReverted, +}) { const { notify } = useNotifications(); const revisions = useWorkflowRevisions(owner, file); const revert = useRevertWorkflowRevision(); const [pendingRevision, setPendingRevision] = useState(null); + const [confirmRevision, setConfirmRevision] = useState(null); const [warnings, setWarnings] = useState(null); + const items = revisions.data?.revisions ?? []; + const latestRevision = items[0]?.revision ?? null; + function doRevert(revision, saveAnyway = false) { setPendingRevision(revision); revert.mutate( @@ -37,6 +48,7 @@ export function WorkflowHistoryPanel({ owner, file, onReverted }) { onSuccess: (data) => { setWarnings(null); setPendingRevision(null); + setConfirmRevision(null); notify.success(`Restored revision #${revision}`); onReverted?.(data); }, @@ -52,7 +64,15 @@ export function WorkflowHistoryPanel({ owner, file, onReverted }) { ); } - const items = revisions.data?.revisions ?? []; + function onRowClick(rev) { + if (rev.revision === latestRevision) { + onSelectRevision?.(null); + return; + } + onSelectRevision?.(rev.revision, rev); + } + + const confirmRev = confirmRevision != null ? items.find((r) => r.revision === confirmRevision) : null; return (
@@ -70,36 +90,73 @@ export function WorkflowHistoryPanel({ owner, file, onReverted }) {

No revisions yet.

) : (
    - {items.map((rev) => ( -
  • -
    -
    #{rev.revision}
    -
    {formatTime(rev.created_at)}
    -
    - {reasonLabel(rev.reason, rev.meta)} -
    -
    - -
  • - ))} +
    +
    + #{rev.revision} + {isCurrent ? ( + Current + ) : null} +
    +
    {formatTime(rev.created_at)}
    +
    + {reasonLabel(rev.reason, rev.meta)} +
    +
    + {!isCurrent ? ( + + ) : null} + + ); + })}
)}
+ {confirmRev ? ( + setConfirmRevision(null)} + onConfirm={() => doRevert(confirmRev.revision, false)} + /> + ) : null} {warnings ? ( +
+

{title}

+

{message}

+
+ + +
+
+
+ +
+ + ); +} + +export function WorkflowRevisionBanner({ + owner, + file, + revision, + createdAt, + onBackToCurrent, + onReverted, +}) { + const { notify } = useNotifications(); + const revert = useRevertWorkflowRevision(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [warnings, setWarnings] = useState(null); + + function doRevert(saveAnyway = false) { + revert.mutate( + { owner, file, revision, saveAnyway }, + { + onSuccess: (data) => { + setWarnings(null); + setConfirmOpen(false); + notify.success(`Restored revision #${revision}`); + onReverted?.(data); + }, + onError: (err) => { + if (isSaveWarningsError(err)) { + setWarnings({ items: saveWarningsFromError(err) }); + return; + } + notify.error(errorMessage(err)); + }, + }, + ); + } + + return ( + <> +
+ + + Viewing revision #{revision} from {formatTime(createdAt)} + +
+ + +
+
+ setConfirmOpen(false)} + onConfirm={() => doRevert(false)} + /> + {warnings ? ( + setWarnings(null)} + onSaveAnyway={() => doRevert(true)} + /> + ) : null} + + ); +} + +export { ConfirmDialog }; diff --git a/packages/web/src/pages/EventDetailPage.jsx b/packages/web/src/pages/EventDetailPage.jsx index 0a90357..bf5a3dd 100644 --- a/packages/web/src/pages/EventDetailPage.jsx +++ b/packages/web/src/pages/EventDetailPage.jsx @@ -103,6 +103,12 @@ export function EventDetailPage() {
Duration
{run.duration_ms != null ? `${run.duration_ms}ms` : "โ€”"}
+
+
Revision
+
+ {run.workflow_revision != null ? `#${run.workflow_revision}` : "unknown version"} +
+
{run.error ? ( @@ -111,7 +117,7 @@ export function EventDetailPage() { ) : null} -
+
diff --git a/packages/web/src/pages/EventsPage.jsx b/packages/web/src/pages/EventsPage.jsx index 4e867ce..6e645af 100644 --- a/packages/web/src/pages/EventsPage.jsx +++ b/packages/web/src/pages/EventsPage.jsx @@ -50,6 +50,7 @@ export function EventsPage() { Status Workflow + Revision Trigger Started Duration @@ -66,6 +67,9 @@ export function EventsPage() { {r.workflow_name || r.workflow} + + {r.workflow_revision != null ? `#${r.workflow_revision}` : "unknown"} + {r.trigger_type} {r.trigger_detail ? ` ยท ${r.trigger_detail}` : ""} diff --git a/packages/web/src/pages/WorkflowEditPage.jsx b/packages/web/src/pages/WorkflowEditPage.jsx index 74d4061..8b56cd2 100644 --- a/packages/web/src/pages/WorkflowEditPage.jsx +++ b/packages/web/src/pages/WorkflowEditPage.jsx @@ -8,6 +8,7 @@ import { useSaveWorkflow, useSetWorkflowEnabled, useWorkflow, + useWorkflowRevision, } from "../api/hooks.js"; import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx"; import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx"; @@ -19,6 +20,10 @@ import { saveErrorMessage, saveWarningsFromError, } from "../components/workflow/SaveWorkflowWarningsDialog.jsx"; +import { + ConfirmDialog, + WorkflowRevisionBanner, +} from "../components/workflow/WorkflowRevisionBanner.jsx"; import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js"; import { useNotifications } from "../notifications.jsx"; @@ -190,8 +195,14 @@ export function WorkflowEditPage() { const [testOpen, setTestOpen] = useState(false); const [duplicateOpen, setDuplicateOpen] = useState(false); const [saveWarnings, setSaveWarnings] = useState(null); + const [previewRevision, setPreviewRevision] = useState(null); + const [previewMeta, setPreviewMeta] = useState(null); + const [previewBaseline, setPreviewBaseline] = useState(null); + const [discardConfirm, setDiscardConfirm] = useState(null); + const [savePreviewConfirm, setSavePreviewConfirm] = useState(false); const routeKey = `${owner}/${file}`; const [activeKey, setActiveKey] = useState(routeKey); + const previewQuery = useWorkflowRevision(owner, file, previewRevision); if (activeKey !== routeKey) { setActiveKey(routeKey); @@ -200,16 +211,89 @@ export function WorkflowEditPage() { setContentReady(false); setTestOpen(false); setDuplicateOpen(false); + setPreviewRevision(null); + setPreviewMeta(null); + setPreviewBaseline(null); + setDiscardConfirm(null); + setSavePreviewConfirm(false); } useEffect(() => { if (existing.isLoading) return; + if (previewRevision != null) return; if (!contentReady && existing.data?.content != null) { setContent(existing.data.content); setSavedYaml(existing.data.content); setContentReady(true); } - }, [existing.data, existing.isLoading, contentReady]); + }, [existing.data, existing.isLoading, contentReady, previewRevision]); + + useEffect(() => { + if (previewRevision == null || previewQuery.isLoading) return; + if (previewQuery.data?.content != null) { + setContent(previewQuery.data.content); + setPreviewBaseline(previewQuery.data.content); + setPreviewMeta({ created_at: previewQuery.data.created_at }); + } + }, [previewRevision, previewQuery.data, previewQuery.isLoading]); + + function isDirty() { + const baseline = previewRevision != null ? previewBaseline : savedYaml; + return contentReady && baseline != null && content !== baseline; + } + + function exitPreview() { + setPreviewRevision(null); + setPreviewMeta(null); + setPreviewBaseline(null); + existing.refetch().then((result) => { + const next = result.data?.content; + if (next != null) { + setContent(next); + setSavedYaml(next); + } + }); + } + + function applySelectRevision(revision, meta) { + if (revision == null) { + exitPreview(); + return; + } + setPreviewRevision(revision); + if (meta?.created_at) { + setPreviewMeta({ created_at: meta.created_at }); + } + } + + function onSelectRevision(revision, meta) { + if (isDirty()) { + setDiscardConfirm(() => () => applySelectRevision(revision, meta)); + return; + } + applySelectRevision(revision, meta); + } + + function onBackToCurrent() { + if (isDirty()) { + setDiscardConfirm(() => () => exitPreview()); + return; + } + exitPreview(); + } + + function onReverted() { + setPreviewRevision(null); + setPreviewMeta(null); + setPreviewBaseline(null); + existing.refetch().then((result) => { + const next = result.data?.content; + if (next != null) { + setContent(next); + setSavedYaml(next); + } + }); + } function onSave(saveAnyway = false) { save.mutate( @@ -218,7 +302,13 @@ export function WorkflowEditPage() { onSuccess: () => { setSavedYaml(content); setSaveWarnings(null); + setSavePreviewConfirm(false); notify.success("Workflow saved"); + if (previewRevision != null) { + setPreviewRevision(null); + setPreviewMeta(null); + setPreviewBaseline(null); + } }, onError: (err) => { if (isSaveWarningsError(err)) { @@ -230,6 +320,15 @@ export function WorkflowEditPage() { ); } + function onSaveRequest() { + const liveYaml = existing.data?.content; + if (previewRevision != null && content !== liveYaml) { + setSavePreviewConfirm(true); + return; + } + onSave(false); + } + if (existing.isLoading) { return (
@@ -254,6 +353,8 @@ export function WorkflowEditPage() { const yamlOk = !parsedDoc.parseError; const workflowName = parsedDoc.doc?.name?.trim(); const pageTitle = workflowName ? `${workflowName}` : file; + const isPreviewing = previewRevision != null; + const previewLoading = isPreviewing && previewQuery.isLoading; return ( <> @@ -265,17 +366,20 @@ export function WorkflowEditPage() { } savePending={save.isPending} - saveDisabled={!contentReady} + saveDisabled={!contentReady || previewLoading} saveError={save.isError && !saveWarnings ? saveErrorMessage(save.error) : null} - onSave={() => onSave(false)} - onTest={() => setTestOpen(true)} - onDuplicate={() => setDuplicateOpen(true)} - onToggleEnabled={() => - setEnabled.mutate({ - owner, - file, - enabled: existing.data?.parsed?.enabled === false, - }) + onSave={() => onSaveRequest()} + onTest={isPreviewing ? undefined : () => setTestOpen(true)} + onDuplicate={isPreviewing ? undefined : () => setDuplicateOpen(true)} + onToggleEnabled={ + isPreviewing + ? undefined + : () => + setEnabled.mutate({ + owner, + file, + enabled: existing.data?.parsed?.enabled === false, + }) } enabled={existing.data?.parsed?.enabled !== false} enablePending={setEnabled.isPending} @@ -284,30 +388,40 @@ export function WorkflowEditPage() { >
- setTestOpen(false)} - /> + {isPreviewing && previewMeta ? ( + + ) : null} + {previewLoading ? ( +
+ +
+ ) : ( + setTestOpen(false)} + /> + )}
{ - existing.refetch().then((result) => { - const next = result.data?.content; - if (next != null) { - setContent(next); - setSavedYaml(next); - } - }); - }} + owner={owner} + file={file} + previewRevision={previewRevision} + onSelectRevision={onSelectRevision} + onReverted={onReverted} />
@@ -333,6 +447,33 @@ export function WorkflowEditPage() { onSaveAnyway={() => onSave(true)} /> ) : null} + {savePreviewConfirm ? ( + setSavePreviewConfirm(false)} + onConfirm={() => onSave(false)} + /> + ) : null} + {discardConfirm ? ( + setDiscardConfirm(null)} + onConfirm={() => { + const action = discardConfirm; + setDiscardConfirm(null); + action?.(); + }} + /> + ) : null} ); }