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
+92 -7
View File
@@ -24,6 +24,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
type FlowDef_Input,
type FlowDetail,
FlowsService,
type MessageSpec,
type NodeDef_Input,
@@ -55,6 +56,8 @@ import {
flowsQueryOptions,
nodeTypesQueryOptions,
useAutosave,
useDiscardDraft,
usePublish,
} from "./queries"
import { useFlowSocket } from "./useFlowSocket"
@@ -184,7 +187,13 @@ function uniqueNodeId(existing: NodeDef_Input[], type: string): string {
}
}
function FlowEditorInner({ flowName }: { flowName: string }) {
function FlowEditorInner({
flowName,
onReload,
}: {
flowName: string
onReload: () => void
}) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { showErrorToast } = useCustomToast()
@@ -195,7 +204,15 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
const { data: detail } = useSuspenseQuery(flowQueryOptions(flowName))
const { data: nodeTypeInfo } = useQuery(nodeTypesQueryOptions())
const { save, flush, mutation: saving } = useAutosave(flowName)
const {
save,
flush,
conflict,
resolveConflict,
mutation: saving,
} = useAutosave(flowName)
const publish = usePublish(flowName)
const discard = useDiscardDraft(flowName)
const [definitions, setDefinitions] = useState<NodeDef_Input[]>(
() => detail.definition.nodes ?? [],
@@ -672,6 +689,17 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
flows={flows.data}
active={flowName}
saving={saving.isPending}
hasDraft={detail.has_draft ?? false}
publishing={publish.isPending || saving.isPending}
onPublish={async () => {
// Publish what was actually stored: the version only advances
// once the queued save has landed.
await flush()
const current = queryClient.getQueryData<FlowDetail>(
flowKeys.detail(flowName),
)
publish.mutate(current?.definition.version ?? 1)
}}
onEditFlow={() => {
setSelectedId(null)
setFlowPanelOpen(true)
@@ -685,8 +713,9 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
issues={issues}
running={runMutation.isPending}
onAddNode={() => setPaletteOpen(true)}
onRun={() => {
flush()
onRun={async () => {
// Running executes what is stored, so the queued edit goes first.
await flush()
runMutation.mutate()
}}
onFocusNode={focusNode}
@@ -718,11 +747,23 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
flush()
save({ ...next, nodes: definitions })
}}
onRename={(newName) => {
flush()
onRename={async (newName) => {
await flush()
renameMutation.mutate(newName)
}}
onDelete={() => deleteMutation.mutate()}
hasDraft={detail.has_draft ?? false}
discarding={discard.isPending}
onDiscardDraft={() => {
discard.mutate(undefined, {
// The published document replaces what is on the canvas, and the
// version counter goes back with it.
onSuccess: () => {
setFlowPanelOpen(false)
onReload()
},
})
}}
onClose={() => setFlowPanelOpen(false)}
/>
@@ -833,6 +874,42 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
</DialogFooter>
</DialogContent>
</Dialog>
{/*
* Not dismissable: until one version wins, every further save fails, so
* there is nothing useful to go back to.
*/}
<Dialog open={conflict}>
<DialogContent
data-testid="save-conflict"
showCloseButton={false}
onEscapeKeyDown={(event) => event.preventDefault()}
onPointerDownOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>Someone else changed this flow</DialogTitle>
<DialogDescription>
Another editor saved <span className="font-mono">{flowName}</span>{" "}
while you were working on it. Load their version, or keep yours
and write over theirs.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button
variant="ghost"
onClick={() => {
void resolveConflict("theirs")
onReload()
}}
>
Load theirs
</Button>
<Button onClick={() => void resolveConflict("mine")}>
Keep mine
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -857,6 +934,10 @@ export function FlowEditor({ flowName }: { flowName: string }) {
const onAuthFailure = useCallback(() => {
navigate({ to: "/login" })
}, [navigate])
// Bumped when the canvas has to take the server's document over its own:
// local edits live in state seeded on mount, so a remount is the reset.
const [epoch, setEpoch] = useState(0)
const reload = useCallback(() => setEpoch((n) => n + 1), [])
useFlowSocket(onAuthFailure)
@@ -871,7 +952,11 @@ export function FlowEditor({ flowName }: { flowName: string }) {
* it is what makes `fitView` run once per flow: xyflow queues the fit on
* mount and resolves it as soon as the nodes have been measured.
*/}
<FlowEditorInner key={flowName} flowName={flowName} />
<FlowEditorInner
key={`${flowName}:${epoch}`}
flowName={flowName}
onReload={reload}
/>
</ReactFlowProvider>
)
}
@@ -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>
</>
)
}
+28 -2
View File
@@ -114,11 +114,17 @@ export function FlowTabs({
flows,
active,
saving,
hasDraft,
publishing,
onPublish,
onEditFlow,
}: {
flows: FlowSummary[]
active: string
saving: boolean
hasDraft: boolean
publishing: boolean
onPublish: () => void
onEditFlow: () => void
}) {
const [dialogOpen, setDialogOpen] = useState(false)
@@ -143,13 +149,18 @@ export function FlowTabs({
to="/flows/$flowName"
params={{ flowName: flow.name }}
className={cn(
"shrink-0 snap-start rounded-full px-3 py-1.5 text-sm transition-colors",
"flex shrink-0 snap-start items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors",
flow.name === active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50",
)}
>
{flow.title || flow.name}
{flow.has_draft ? (
<span className="size-1.5 shrink-0 rounded-full bg-primary">
<span className="sr-only">Unpublished changes</span>
</span>
) : null}
</Link>
))}
</div>
@@ -202,9 +213,24 @@ export function FlowTabs({
? "Reconnecting to the engine"
: saving
? "Saving"
: "All changes saved"}
: hasDraft
? "Saved — publish to put it live"
: "All changes saved"}
</TooltipContent>
</Tooltip>
{hasDraft ? (
<Button
variant="outline"
size="sm"
className="shrink-0 rounded-full"
onClick={onPublish}
disabled={publishing}
data-testid="publish-flow"
>
{publishing ? "Publishing…" : "Publish"}
</Button>
) : null}
</motion.div>
<NewFlowDialog open={dialogOpen} onOpenChange={setDialogOpen} />
+99 -15
View File
@@ -3,9 +3,9 @@ import {
useMutation,
useQueryClient,
} from "@tanstack/react-query"
import { useCallback, useEffect, useRef } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { type FlowDef_Input, FlowsService } from "@/client"
import { ApiError, type FlowDef_Input, FlowsService } from "@/client"
export const flowKeys = {
all: ["flows"] as const,
@@ -45,55 +45,139 @@ export const messageHistoryQueryOptions = (name: string, message: string) => ({
})
const AUTOSAVE_DELAY = 800
/** How long to wait for a save in flight before sending the next one. */
const RETRY_DELAY = 100
/** Publish the unpublished changes, which is what puts them on the engine. */
export function usePublish(name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (version: number) =>
FlowsService.publishFlow({ name, requestBody: { version } }),
onSuccess: (detail) => {
queryClient.setQueryData(flowKeys.detail(name), detail)
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
},
})
}
/** Throw the unpublished changes away and go back to what is running. */
export function useDiscardDraft(name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => FlowsService.discardDraft({ name }),
onSuccess: (detail) => {
queryClient.setQueryData(flowKeys.detail(name), detail)
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
},
})
}
/**
* Saves the flow a moment after the last edit, and immediately when the editor
* needs the server to be current (closing a panel, switching flow, running).
*
* Identical documents are skipped server-side, so a quiet canvas writes nothing.
* Saving writes a draft — the engine keeps running the published version until
* someone publishes. Identical documents are skipped server-side, so a quiet
* canvas writes nothing.
*
* Every save carries the version it is based on. If another client saved in
* between, the server refuses rather than discarding their work, and `conflict`
* turns true for the editor to ask which version wins.
*/
export function useAutosave(name: string): {
save: (definition: FlowDef_Input) => void
flush: () => void
/** Send what is queued and resolve once the server has it. */
flush: () => Promise<void>
conflict: boolean
resolveConflict: (mode: "theirs" | "mine") => Promise<void>
mutation: UseMutationResult<unknown, unknown, FlowDef_Input, unknown>
} {
const queryClient = useQueryClient()
const pending = useRef<FlowDef_Input | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// The freshest version this client has seen. Local edits keep the document
// they were made against, so the version has to be stamped on at send time.
const version = useRef<number | null>(null)
const sent = useRef<FlowDef_Input | null>(null)
const inFlight = useRef(false)
const [conflict, setConflict] = useState(false)
const mutation = useMutation({
mutationFn: (definition: FlowDef_Input) =>
FlowsService.saveFlow({ name, requestBody: definition }),
FlowsService.saveFlow({
name,
requestBody: { ...definition, version: version.current ?? undefined },
}),
onMutate: (definition) => {
inFlight.current = true
sent.current = definition
},
onSettled: () => {
inFlight.current = false
},
onSuccess: (detail) => {
version.current = detail.definition.version ?? null
// Write the server's answer straight into the cache: invalidating would
// pull the document back out from under edits still in flight.
queryClient.setQueryData(flowKeys.detail(name), detail)
queryClient.invalidateQueries({ queryKey: flowKeys.all, exact: true })
},
onError: (error) => {
if (error instanceof ApiError && error.status === 409) setConflict(true)
},
})
// react-query hands back a new mutation object on every render, so flushing
// has to hang off `mutate`, which is stable. Depending on the whole mutation
// re-ran the effect below on every render, and its cleanup cancelled the
// pending save before it ever fired.
const { mutate } = mutation
const flush = useCallback(() => {
const { mutate, mutateAsync } = mutation
const flush = useCallback(async (): Promise<void> => {
if (timer.current) {
clearTimeout(timer.current)
timer.current = null
}
const definition = pending.current
pending.current = null
if (definition) {
mutate(definition)
if (!definition) return
// Two saves in flight at once would race for the same version, and the
// loser would look like someone else's edit.
if (inFlight.current) {
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY))
return flush()
}
}, [mutate])
pending.current = null
// A rejection here is a conflict, which the dialog handles; callers waiting
// on the flush only need to know the attempt is over.
await mutateAsync(definition).catch(() => undefined)
}, [mutateAsync])
const resolveConflict = useCallback(
async (mode: "theirs" | "mine") => {
setConflict(false)
if (mode === "theirs") {
pending.current = null
sent.current = null
version.current = null
await queryClient.invalidateQueries({
queryKey: flowKeys.detail(name),
})
return
}
const current = await FlowsService.readFlow({ name })
version.current = current.definition.version ?? null
const definition = pending.current ?? sent.current
pending.current = null
if (definition) mutate(definition)
},
[mutate, name, queryClient],
)
const save = useCallback(
(definition: FlowDef_Input) => {
pending.current = definition
if (timer.current) clearTimeout(timer.current)
timer.current = setTimeout(flush, AUTOSAVE_DELAY)
timer.current = setTimeout(() => void flush(), AUTOSAVE_DELAY)
},
[flush],
)
@@ -101,15 +185,15 @@ export function useAutosave(name: string): {
// Leaving the tab is the last chance to persist what is still queued.
useEffect(() => {
const onHidden = () => {
if (document.visibilityState === "hidden") flush()
if (document.visibilityState === "hidden") void flush()
}
document.addEventListener("visibilitychange", onHidden)
return () => {
document.removeEventListener("visibilitychange", onHidden)
// Unmounting is a flow switch, not a reason to drop a queued edit.
flush()
void flush()
}
}, [flush])
return { save, flush, mutation }
return { save, flush, conflict, resolveConflict, mutation }
}
@@ -1,7 +1,9 @@
import { useQueryClient } from "@tanstack/react-query"
import { useEffect, useRef } from "react"
import { OpenAPI } from "@/client"
import { liveStore } from "./liveStore"
import { flowKeys } from "./queries"
const RECONNECT_MIN = 1000
const RECONNECT_MAX = 30000
@@ -42,6 +44,7 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
const retry = useRef(RECONNECT_MIN)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const closed = useRef(false)
const queryClient = useQueryClient()
useEffect(() => {
closed.current = false
@@ -87,6 +90,9 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
break
case "pipeline_rebuilt":
liveStore.setStatuses(message.nodes)
// Someone published, here or in another tab: the draft markers on
// the flow chips are stale until the list is fetched again.
queryClient.invalidateQueries({ queryKey: flowKeys.all })
break
}
}
@@ -111,5 +117,5 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
socket.current?.close()
liveStore.setConnected(false)
}
}, [onAuthFailure])
}, [onAuthFailure, queryClient])
}