Separate editing from running with a draft/publish split

Edits autosave to flow.draft.json and nodes.draft/ instead of the files the
engine reads, so the pipeline keeps running the published version until
someone publishes. Every save carries the version it was based on: a second
client editing the same flow is refused with 409 and offered the choice
between their version and its own, rather than silently overwriting.

Draft saves no longer rebuild the pipeline; validation and node status for a
draft come from a throwaway build that never touches live state.

Also fixes a latent bug where an empty state backend is falsy, so Pipeline
quietly built itself a second, private state and left message history empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-08-15 23:13:15 +02:00
co-authored by Claude Fable 5
parent 36be6f1081
commit 606ab3c423
18 changed files with 1159 additions and 132 deletions
@@ -29,6 +29,9 @@ export function FlowPanel({
onChange,
onRename,
onDelete,
hasDraft,
discarding,
onDiscardDraft,
onClose,
}: {
open: boolean
@@ -38,10 +41,14 @@ export function FlowPanel({
onChange: (next: FlowDef_Input) => void
onRename: (newName: string) => void
onDelete: () => void
hasDraft: boolean
discarding: boolean
onDiscardDraft: () => void
onClose: () => void
}) {
const [name, setName] = useState(definition.name)
const [confirmOpen, setConfirmOpen] = useState(false)
const [discardOpen, setDiscardOpen] = useState(false)
const valid = NAME_PATTERN.test(name)
const changed = name !== definition.name
@@ -117,6 +124,26 @@ export function FlowPanel({
: `${nodeCount} node${nodeCount === 1 ? "" : "s"}.`}
</p>
</div>
{hasDraft ? (
<div className="grid gap-2">
<span className={PANEL_SECTION}>Unpublished changes</span>
<p className="text-sm text-muted-foreground">
The engine is still running the last published version of this
flow.
</p>
<Button
variant="outline"
size="sm"
className="h-8 justify-self-start"
disabled={discarding}
onClick={() => setDiscardOpen(true)}
data-testid="discard-draft"
>
{discarding ? "Discarding…" : "Discard changes"}
</Button>
</div>
) : null}
</div>
</SidePanel>
@@ -149,6 +176,34 @@ export function FlowPanel({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Discard the unpublished changes?</DialogTitle>
<DialogDescription>
The canvas goes back to the version the engine is running. What
you edited since is dropped, though the flow store's git history
keeps it.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
Keep editing
</Button>
<Button
variant="destructive"
onClick={() => {
setDiscardOpen(false)
onDiscardDraft()
}}
data-testid="confirm-discard-draft"
>
Discard changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}