Skip to content

Commit 6e45a79

Browse files
outof-placeclaude
andcommitted
fix(dashboard): load the rolled back version into the editor canvas
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015kfeohXE66xx7RJPxZ2pvH
1 parent a81c8c8 commit 6e45a79

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

apps/dashboard/components/cockpit/screens/workflow-editor.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
import { useWorkflowValidationController } from "@/lib/workflow-editor/use-validation-controller";
7171
import { useWorkflowDataCatalog } from "@/lib/workflow-editor/use-workflow-data-catalog";
7272
import {
73+
draftDiffersFromDeployed,
7374
workflowDeploymentAfterSave,
7475
workflowEditorActions,
7576
} from "@/lib/workflow-editor/editor-actions";
@@ -448,6 +449,20 @@ export function WorkflowEditorScreen({
448449
: null,
449450
);
450451
const dirty = editorHistoryIsDirty(editorHistory, semanticKey);
452+
// Independent of `dirty` (canvas vs. saved draft): flags the saved draft no
453+
// longer matching what is deployed, which a rollback produces without ever
454+
// touching the canvas or the draft, so `dirty` alone would stay false.
455+
const deployedSemanticKey = useMemo(
456+
() => (deployed ? semanticKeyForDefinition(deployed.definition) : null),
457+
[deployed],
458+
);
459+
const draftSemanticKey = baselineDraft
460+
? semanticKeyForDefinition(baselineDraft)
461+
: null;
462+
const showDraftDiffersFromDeployed = draftDiffersFromDeployed(
463+
draftSemanticKey,
464+
deployedSemanticKey,
465+
);
451466
const runnableTriggerIds = useMemo(() => {
452467
if (!canDispatch || !deployed) return new Set<string>();
453468
const deployedTypes = new Map(
@@ -474,6 +489,8 @@ export function WorkflowEditorScreen({
474489
structurallyValid: nodesValid(nodes),
475490
hasDraft: baselineDraft !== null,
476491
});
492+
const canResetToDeployed =
493+
canEdit && deployed !== null && semanticKey !== deployedSemanticKey;
477494

478495
useEffect(() => {
479496
if (!dirty) return;
@@ -975,6 +992,36 @@ export function WorkflowEditorScreen({
975992
}
976993
}
977994

995+
// Rollback and "Reset to deployed" both need the canvas to show a specific,
996+
// already-known definition instead of whatever the draft last held. The
997+
// loaded content is compared against the saved draft (not the definition
998+
// just loaded), so a rollback that lands on a different graph than the
999+
// draft correctly shows as unsaved rather than being silently treated as
1000+
// the new baseline.
1001+
function loadDefinitionIntoCanvas(definition: WorkflowDefinition) {
1002+
const flow = toFlowDefinition(definition);
1003+
const nextDocument: WorkflowEditorDocument = {
1004+
nodes: flow.nodes,
1005+
edges: flow.edges,
1006+
budgets: executionLimitsFromDefinition(definition),
1007+
repositoryScope: repositoryScopeFromDefinition(definition),
1008+
edgeGeometry: structuredClone(edgeGeometry),
1009+
};
1010+
setSchemaVersion(flow.schemaVersion);
1011+
editorDocumentRef.current = nextDocument;
1012+
dispatchEditorHistory({
1013+
type: "reset",
1014+
value: nextDocument,
1015+
savedSemanticKey: baselineDraft ? semanticKeyForDefinition(baselineDraft) : null,
1016+
});
1017+
setFitSignal((signal) => signal + 1);
1018+
}
1019+
1020+
function resetToDeployed() {
1021+
if (!deployed) return;
1022+
loadDefinitionIntoCanvas(deployed.definition);
1023+
}
1024+
9781025
async function rollback(version: number) {
9791026
setBusy(`rollback-${version}`);
9801027
setError(null);
@@ -991,6 +1038,7 @@ export function WorkflowEditorScreen({
9911038
const body = (await res.json()) as WorkflowDefinitionDeploymentResponse;
9921039
setDeployed(body.deployed);
9931040
setMetas((prev) => prev.map((meta) => (meta.id === body.meta.id ? body.meta : meta)));
1041+
loadDefinitionIntoCanvas(body.deployed.definition);
9941042
setConfirmRestore(null);
9951043
} catch (err) {
9961044
setError(err instanceof Error ? err.message : "Unable to roll back version");
@@ -1258,6 +1306,14 @@ export function WorkflowEditorScreen({
12581306
headerVersionBadge={deployed ? `deployed v${deployed.version}` : "not deployed"}
12591307
headerInlineExtra={
12601308
<>
1309+
{showDraftDiffersFromDeployed && (
1310+
<span
1311+
title="The saved draft no longer matches the deployed version. Use Reset to deployed to load what is live into the canvas."
1312+
className="rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.04em] text-amber-800"
1313+
>
1314+
Draft differs from deployed
1315+
</span>
1316+
)}
12611317
{repositoryScopeSummary !== null && (
12621318
<span
12631319
title="Repositories pinned to this workflow. Every ticket entering it inherits them."
@@ -1301,6 +1357,16 @@ export function WorkflowEditorScreen({
13011357
{busy === "deploy" ? "Deploying…" : "Deploy"}
13021358
</button>
13031359
)}
1360+
{canEdit && deployed !== null && (
1361+
<button
1362+
onClick={resetToDeployed}
1363+
disabled={!canResetToDeployed || busy !== null}
1364+
title="Load the deployed version's nodes and edges into the canvas."
1365+
className={headerButtonClass}
1366+
>
1367+
Reset to deployed
1368+
</button>
1369+
)}
13041370
<button
13051371
onClick={() => {
13061372
setDefsOpen((o) => !o);

apps/dashboard/lib/workflow-editor/editor-actions.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
WorkflowDefinitionValidationResponse,
66
} from "@shared/contracts";
77
import {
8+
draftDiffersFromDeployed,
89
workflowDeploymentAfterSave,
910
workflowEditorActions,
1011
} from "./editor-actions.ts";
@@ -72,3 +73,20 @@ test("dirty deploy stops when saved-snapshot validation diverges from the immedi
7273
validation: authoritative,
7374
});
7475
});
76+
77+
test("draftDiffersFromDeployed flags a saved draft that no longer matches what is deployed", () => {
78+
// AIW-288: a rollback rewrites the deployed pointer without touching the
79+
// saved draft, so the two semantic keys diverge even though nothing about
80+
// the draft itself changed.
81+
assert.equal(draftDiffersFromDeployed('{"v":9}', '{"v":6}'), true);
82+
});
83+
84+
test("draftDiffersFromDeployed reports no divergence once the draft matches deployed again", () => {
85+
assert.equal(draftDiffersFromDeployed('{"v":6}', '{"v":6}'), false);
86+
});
87+
88+
test("draftDiffersFromDeployed has nothing to compare when either side is missing", () => {
89+
assert.equal(draftDiffersFromDeployed(null, '{"v":6}'), false);
90+
assert.equal(draftDiffersFromDeployed('{"v":9}', null), false);
91+
assert.equal(draftDiffersFromDeployed(null, null), false);
92+
});

apps/dashboard/lib/workflow-editor/editor-actions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ export function workflowEditorActions(input: WorkflowEditorActionInput) {
1414
};
1515
}
1616

17+
/**
18+
* Independent of the canvas "dirty" flag (canvas vs. saved draft): a rollback
19+
* changes what is deployed without ever touching the saved draft, so the
20+
* draft can look saved (canvas matches it) while no longer matching what is
21+
* live. Either key being absent (no draft yet, or nothing deployed yet) means
22+
* there is nothing to compare, so it reports no divergence.
23+
*/
24+
export function draftDiffersFromDeployed(
25+
draftSemanticKey: string | null,
26+
deployedSemanticKey: string | null,
27+
): boolean {
28+
return (
29+
draftSemanticKey !== null &&
30+
deployedSemanticKey !== null &&
31+
draftSemanticKey !== deployedSemanticKey
32+
);
33+
}
34+
1735
export type WorkflowDeploymentSaveDecision =
1836
| { kind: "ready"; validation: WorkflowDefinitionValidationResponse }
1937
| { kind: "invalid"; validation: WorkflowDefinitionValidationResponse }

0 commit comments

Comments
 (0)