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
@@ -161,12 +161,21 @@ export const FlowDetailSchema = {
|
||||
type: 'array',
|
||||
title: 'Issues',
|
||||
default: []
|
||||
},
|
||||
has_draft: {
|
||||
type: 'boolean',
|
||||
title: 'Has Draft',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['definition'],
|
||||
title: 'FlowDetail',
|
||||
description: 'A flow plus how it is currently doing.'
|
||||
description: `A flow plus how it is currently doing.
|
||||
|
||||
\`\`definition\`\` is the working copy — the unpublished draft when there is
|
||||
one — because that is what the editor shows. \`\`nodes\`\` reports the
|
||||
published flow, which is what is actually running.`
|
||||
} as const;
|
||||
|
||||
export const FlowInput_InputSchema = {
|
||||
@@ -252,6 +261,11 @@ export const FlowSummarySchema = {
|
||||
type: 'integer',
|
||||
title: 'Error Count',
|
||||
default: 0
|
||||
},
|
||||
has_draft: {
|
||||
type: 'boolean',
|
||||
title: 'Has Draft',
|
||||
default: false
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -631,6 +645,18 @@ export const PrivateUserCreateSchema = {
|
||||
title: 'PrivateUserCreate'
|
||||
} as const;
|
||||
|
||||
export const PublishRequestSchema = {
|
||||
properties: {
|
||||
version: {
|
||||
type: 'integer',
|
||||
title: 'Version'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['version'],
|
||||
title: 'PublishRequest'
|
||||
} as const;
|
||||
|
||||
export const RenameRequestSchema = {
|
||||
properties: {
|
||||
new_name: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
|
||||
export class FlowsService {
|
||||
/**
|
||||
@@ -55,7 +55,12 @@ export class FlowsService {
|
||||
|
||||
/**
|
||||
* Save Flow
|
||||
* Create or replace a flow. Saving the same content again changes nothing.
|
||||
* Save unpublished changes to a flow.
|
||||
*
|
||||
* This writes a draft: the running pipeline keeps the published version until
|
||||
* someone publishes. ``version`` is the one the editor last saw — a mismatch
|
||||
* means another client saved in between and answers 409 rather than throwing
|
||||
* their work away.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.requestBody
|
||||
@@ -98,6 +103,51 @@ export class FlowsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish Flow
|
||||
* Deploy the unpublished changes: the engine picks them up from here.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.requestBody
|
||||
* @returns FlowDetail Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static publishFlow(data: FlowsPublishFlowData): CancelablePromise<FlowsPublishFlowResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/publish',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard Draft
|
||||
* Throw the unpublished changes away and go back to what is running.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @returns FlowDetail Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static discardDraft(data: FlowsDiscardDraftData): CancelablePromise<FlowsDiscardDraftResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/flows/{name}/discard-draft',
|
||||
path: {
|
||||
name: data.name
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename Flow
|
||||
* Rename a flow, along with every reference to its messages.
|
||||
@@ -124,7 +174,7 @@ export class FlowsService {
|
||||
|
||||
/**
|
||||
* Read Node Source
|
||||
* Read a node's Python source.
|
||||
* Read a node's Python source, including unpublished edits.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.nodeId
|
||||
@@ -147,7 +197,11 @@ export class FlowsService {
|
||||
|
||||
/**
|
||||
* Save Node Source
|
||||
* Save a node's source and report whether it loads.
|
||||
* Save a node's source as an unpublished edit and report whether it loads.
|
||||
*
|
||||
* The answer comes from compiling the code rather than from the running
|
||||
* pipeline: a draft is not deployed, and compiling is both faster and more
|
||||
* precise about what the author just typed.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.nodeId
|
||||
@@ -195,6 +249,9 @@ export class FlowsService {
|
||||
/**
|
||||
* Run Flow
|
||||
* Run every node of a flow once.
|
||||
*
|
||||
* With unpublished changes this runs the draft, so the button matches what is
|
||||
* on the canvas. Nothing is deployed by running it.
|
||||
* @param data The data for the request.
|
||||
* @param data.name
|
||||
* @param data.requestBody
|
||||
|
||||
@@ -41,11 +41,16 @@ export type FlowDef_Output = {
|
||||
|
||||
/**
|
||||
* A flow plus how it is currently doing.
|
||||
*
|
||||
* ``definition`` is the working copy — the unpublished draft when there is
|
||||
* one — because that is what the editor shows. ``nodes`` reports the
|
||||
* published flow, which is what is actually running.
|
||||
*/
|
||||
export type FlowDetail = {
|
||||
definition: FlowDef_Output;
|
||||
nodes?: Array<NodeStatusPublic>;
|
||||
issues?: Array<ValidationIssue>;
|
||||
has_draft?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -81,6 +86,7 @@ export type FlowSummary = {
|
||||
title?: string;
|
||||
node_count?: number;
|
||||
error_count?: number;
|
||||
has_draft?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -213,6 +219,10 @@ export type PrivateUserCreate = {
|
||||
is_verified?: boolean;
|
||||
};
|
||||
|
||||
export type PublishRequest = {
|
||||
version: number;
|
||||
};
|
||||
|
||||
export type RenameRequest = {
|
||||
new_name: string;
|
||||
};
|
||||
@@ -337,6 +347,19 @@ export type FlowsDeleteFlowData = {
|
||||
|
||||
export type FlowsDeleteFlowResponse = (Message);
|
||||
|
||||
export type FlowsPublishFlowData = {
|
||||
name: string;
|
||||
requestBody: PublishRequest;
|
||||
};
|
||||
|
||||
export type FlowsPublishFlowResponse = (FlowDetail);
|
||||
|
||||
export type FlowsDiscardDraftData = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type FlowsDiscardDraftResponse = (FlowDetail);
|
||||
|
||||
export type FlowsRenameFlowData = {
|
||||
name: string;
|
||||
requestBody: RenameRequest;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user