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}
>
);
}
|