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:
co-authored by
Claude Fable 5
parent
36be6f1081
commit
606ab3c423
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user