Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support workflow multi version recovery #8484

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions api/controllers/console/app/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,25 @@ def post(self, app_model: App):
}


class PublishedAllWorkflowApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_fields)
def get(self, app_model: App):
"""
Get published workflows
"""

if not current_user.is_editor:
raise Forbidden()

workflow_service = WorkflowService()
workflows = workflow_service.get_all_published_workflow(app_model=app_model)
return workflows


api.add_resource(DraftWorkflowApi, "/apps/<uuid:app_id>/workflows/draft")
api.add_resource(DraftWorkflowImportApi, "/apps/<uuid:app_id>/workflows/draft/import")
api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps/<uuid:app_id>/advanced-chat/workflows/draft/run")
Expand All @@ -463,6 +482,7 @@ def post(self, app_model: App):
WorkflowDraftRunIterationNodeApi, "/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run"
)
api.add_resource(PublishedWorkflowApi, "/apps/<uuid:app_id>/workflows/publish")
api.add_resource(PublishedAllWorkflowApi, "/apps/<uuid:app_id>/workflows/publish/all")
api.add_resource(DefaultBlockConfigsApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs")
api.add_resource(
DefaultBlockConfigApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs/<string:block_type>"
Expand Down
1 change: 1 addition & 0 deletions api/fields/workflow_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def format(self, value):
"graph": fields.Raw(attribute="graph_dict"),
"features": fields.Raw(attribute="features_dict"),
"hash": fields.String(attribute="unique_hash"),
"version": fields.String(attribute="version"),
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account"),
"created_at": TimestampField,
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
Expand Down
34 changes: 34 additions & 0 deletions api/services/workflow_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from datetime import datetime, timezone
from typing import Optional

from sqlalchemy import desc

from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from core.app.segments import Variable
Expand Down Expand Up @@ -71,6 +73,38 @@ def get_published_workflow(self, app_model: App) -> Optional[Workflow]:

return workflow

def get_all_published_workflow(self, app_model: App) -> list[Workflow]:
"""
Get published workflow
"""

if not app_model.workflow_id:
return None

# fetch published workflow by workflow_id
workflows = (
db.session.query(Workflow)
.filter(Workflow.app_id == app_model.id)
.order_by(desc(Workflow.version))
.limit(10)
.all()
)

if len(workflows) > 1:
workflows[1].version = "current"

for workflow in workflows:
try:
# Try to parse the version as a datetime object
version_datetime = datetime.strptime(workflow.version, "%Y-%m-%d %H:%M:%S.%f")
# If successful, reformat it to the desired format
workflow.version = version_datetime.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
# If parsing fails, then the version is not a datetime string, so we skip formatting
pass

return workflows

def sync_draft_workflow(
self,
*,
Expand Down
50 changes: 29 additions & 21 deletions web/app/components/workflow/header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { StartNodeType } from '../nodes/start/types'
import {
useChecklistBeforePublish,
useIsChatMode,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
useWorkflowMode,
Expand All @@ -34,6 +35,7 @@ import RestoringTitle from './restoring-title'
import ViewHistory from './view-history'
import ChatVariableButton from './chat-variable-button'
import EnvButton from './env-button'
import VersionHistoryModal from './version-history-modal'
import Button from '@/app/components/base/button'
import { useStore as useAppStore } from '@/app/components/app/store'
import { publishWorkflow } from '@/service/workflow'
Expand All @@ -48,11 +50,13 @@ const Header: FC = () => {
const appID = appDetail?.id
const isChatMode = useIsChatMode()
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const { handleNodeSelect } = useNodesInteractions()
const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const toolPublished = useStore(s => s.toolPublished)
const nodes = useNodes<StartNodeType>()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const selectedNode = nodes.find(node => node.data.selected)
const startVariables = startNode?.data.variables
const fileSettings = useFeatures(s => s.features.file)
const variables = useMemo(() => {
Expand All @@ -75,7 +79,6 @@ const Header: FC = () => {
const {
handleLoadBackupDraft,
handleBackupDraft,
handleRestoreFromPublishedWorkflow,
} = useWorkflowRun()
const { handleCheckBeforePublish } = useChecklistBeforePublish()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
Expand Down Expand Up @@ -125,8 +128,10 @@ const Header: FC = () => {
const onStartRestoring = useCallback(() => {
workflowStore.setState({ isRestoring: true })
handleBackupDraft()
handleRestoreFromPublishedWorkflow()
}, [handleBackupDraft, handleRestoreFromPublishedWorkflow, workflowStore])
// clear right panel
if (selectedNode)
handleNodeSelect(selectedNode.id, true)
}, [handleBackupDraft, workflowStore, handleNodeSelect, selectedNode])

const onPublisherToggle = useCallback((state: boolean) => {
if (state)
Expand Down Expand Up @@ -211,24 +216,27 @@ const Header: FC = () => {
}
{
restoring && (
<div className='flex items-center'>
<Button className='text-components-button-secondary-text' onClick={handleShowFeatures}>
<RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' />
{t('workflow.common.features')}
</Button>
<div className='mx-2 w-[1px] h-3.5 bg-gray-200'></div>
<Button
className='mr-2'
onClick={handleCancelRestore}
>
{t('common.operation.cancel')}
</Button>
<Button
onClick={handleRestore}
variant='primary'
>
{t('workflow.common.restore')}
</Button>
<div className='flex flex-col mt-auto'>
<div className='flex items-center justify-end my-4'>
<Button className='text-components-button-secondary-text' onClick={handleShowFeatures}>
<RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' />
{t('workflow.common.features')}
</Button>
<div className='mx-2 w-[1px] h-3.5 bg-gray-200'></div>
<Button
className='mr-2'
onClick={handleCancelRestore}
>
{t('common.operation.cancel')}
</Button>
<Button
onClick={handleRestore}
variant='primary'
>
{t('workflow.common.restore')}
</Button>
</div>
<VersionHistoryModal />
</div>
)
}
Expand Down
51 changes: 51 additions & 0 deletions web/app/components/workflow/header/version-history-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react'
import dayjs from 'dayjs'
import { WorkflowVersion } from '../types'
import cn from '@/utils/classnames'
import type { VersionHistory } from '@/types/workflow'

type VersionHistoryItemProps = {
item: VersionHistory
selectedVersion: string
onClick: (item: VersionHistory) => void
}

const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ item, selectedVersion, onClick }) => {
const formatTime = (time: number) => {
return dayjs.unix(time).format('YYYY-MM-DD HH:mm:ss')
}

const renderVersionLabel = (version: string) => {
switch (version) {
case WorkflowVersion.Draft:
return (
<div className="w-20 py-1 text-center rounded-md bg-[#EAECEF] text-[#707A8A]">
{version}
</div>
)
case WorkflowVersion.Current:
return (
<div className="w-20 py-1 text-center rounded-md bg-[#F2FFF7] text-[#0ECB81]">
{version}
</div>
)
default:
return null
}
}

return (
<div
className={cn(
'flex mb-1 p-2 rounded-lg items-center justify-between h-12',
item.version === selectedVersion ? 'bg-primary-50' : 'hover:bg-primary-50 cursor-pointer',
)}
onClick={() => onClick(item)}
>
<div>{formatTime(item.version === WorkflowVersion.Draft ? item.updated_at : item.created_at)}</div>
{renderVersionLabel(item.version)}
</div>
)
}

export default React.memo(VersionHistoryItem)
45 changes: 45 additions & 0 deletions web/app/components/workflow/header/version-history-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client'
import React, { useState } from 'react'
import useSWR from 'swr'
import { useWorkflowRun } from '../hooks'
import VersionHistoryItem from './version-history-item'
import type { VersionHistory } from '@/types/workflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { fetchPublishedAllWorkflow } from '@/service/workflow'
import Loading from '@/app/components/base/loading'

const VersionHistoryModal = () => {
const [selectedVersion, setSelectedVersion] = useState('draft')
const { handleRestoreFromPublishedWorkflow } = useWorkflowRun()
const appDetail = useAppStore.getState().appDetail
const {
data: versionHistory,
isLoading,
} = useSWR(`/apps/${appDetail?.id}/workflows/publish/all`, fetchPublishedAllWorkflow)

const handleVersionClick = (item: VersionHistory) => {
if (item.version !== selectedVersion) {
setSelectedVersion(item.version)
handleRestoreFromPublishedWorkflow(item)
}
}

return (
<div className='w-[336px] bg-white rounded-2xl border-[0.5px] border-gray-200 shadow-xl p-2'>
{isLoading && (
<div className='flex items-center justify-center h-10'>
<Loading/>
</div>
)}
{versionHistory?.map(item => (
<VersionHistoryItem
key={item.version}
item={item}
selectedVersion={selectedVersion}
onClick={handleVersionClick}
/>
))}
</div>
)
}
export default React.memo(VersionHistoryModal)
36 changes: 14 additions & 22 deletions web/app/components/workflow/hooks/use-workflow-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,10 @@ import { useWorkflowUpdate } from './use-workflow-interactions'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { IOtherOptions } from '@/service/base'
import { ssePost } from '@/service/base'
import {
fetchPublishedWorkflow,
stopWorkflowRun,
} from '@/service/workflow'
import { stopWorkflowRun } from '@/service/workflow'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
import type { VersionHistory } from '@/types/workflow'

export const useWorkflowRun = () => {
const store = useStoreApi()
Expand Down Expand Up @@ -536,24 +534,18 @@ export const useWorkflowRun = () => {
stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`)
}, [])

const handleRestoreFromPublishedWorkflow = useCallback(async () => {
const appDetail = useAppStore.getState().appDetail
const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`)

if (publishedWorkflow) {
const nodes = publishedWorkflow.graph.nodes
const edges = publishedWorkflow.graph.edges
const viewport = publishedWorkflow.graph.viewport!

handleUpdateWorkflowCanvas({
nodes,
edges,
viewport,
})
featuresStore?.setState({ features: publishedWorkflow.features })
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
}
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
const edges = publishedWorkflow.graph.edges
const viewport = publishedWorkflow.graph.viewport!
handleUpdateWorkflowCanvas({
nodes,
edges,
viewport,
})
featuresStore?.setState({ features: publishedWorkflow.features })
workflowStore.getState().setPublishedAt(publishedWorkflow.created_at)
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])

return {
Expand Down
5 changes: 5 additions & 0 deletions web/app/components/workflow/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
WorkflowRunningData,
} from './types'
import { WorkflowContext } from './context'
import type { VersionHistory } from '@/types/workflow'

// #TODO chatVar#
// const MOCK_DATA = [
Expand Down Expand Up @@ -164,6 +165,8 @@ type Shape = {
setShowImportDSLModal: (showImportDSLModal: boolean) => void
showTips: string
setShowTips: (showTips: string) => void
versionHistory: VersionHistory[]
setVersionHistory: (versionHistory: VersionHistory[]) => void
}

export const createWorkflowStore = () => {
Expand Down Expand Up @@ -266,6 +269,8 @@ export const createWorkflowStore = () => {
setShowImportDSLModal: showImportDSLModal => set(() => ({ showImportDSLModal })),
showTips: '',
setShowTips: showTips => set(() => ({ showTips })),
versionHistory: [],
setVersionHistory: versionHistory => set(() => ({ versionHistory })),
}))
}

Expand Down
5 changes: 5 additions & 0 deletions web/app/components/workflow/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,11 @@ export enum WorkflowRunningStatus {
Stopped = 'stopped',
}

export enum WorkflowVersion {
Draft = 'draft',
Current = 'current',
}

export enum NodeRunningStatus {
NotStart = 'not-start',
Waiting = 'waiting',
Expand Down
4 changes: 4 additions & 0 deletions web/service/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export const fetchPublishedWorkflow: Fetcher<FetchWorkflowDraftResponse, string>
return get<FetchWorkflowDraftResponse>(url)
}

export const fetchPublishedAllWorkflow: Fetcher<FetchWorkflowDraftResponse[], string> = (url) => {
return get<FetchWorkflowDraftResponse[]>(url)
}

export const stopWorkflowRun = (url: string) => {
return post<CommonResponse>(url)
}
Expand Down
3 changes: 3 additions & 0 deletions web/types/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,11 @@ export type FetchWorkflowDraftResponse = {
tool_published: boolean
environment_variables?: EnvironmentVariable[]
conversation_variables?: ConversationVariable[]
version: string
}

export type VersionHistory = FetchWorkflowDraftResponse

export type NodeTracingListResponse = {
data: NodeTracing[]
}
Expand Down