From 9565fe9b1b4246a3b0d49ed8f4cd2f6298adc406 Mon Sep 17 00:00:00 2001 From: QuantumGhost Date: Tue, 6 May 2025 18:05:19 +0800 Subject: [PATCH 1/2] fix(api): fix alembic offline mode (#19285) Alembic's offline mode generates SQL from SQLAlchemy migration operations, providing developers with a clear view of database schema changes without requiring an active database connection. However, some migration versions (specifically bbadea11becb and d7999dfa4aae) were performing database schema introspection, which fails in offline mode since it requires an actual database connection. This commit: - Adds offline mode support by detecting context.is_offline_mode() - Skips introspection steps when in offline mode - Adds warning messages in SQL output to inform users that assumptions were made - Prompts users to review the generated SQL for accuracy These changes ensure migrations work consistently in both online and offline modes. Close #19284. --- ...a11becb_add_name_and_size_to_tool_files.py | 56 ++++++++++++------- ..._remove_workflow_node_executions_retry_.py | 32 +++++++---- api/models/tools.py | 36 ++---------- 3 files changed, 61 insertions(+), 63 deletions(-) diff --git a/api/migrations/versions/2024_10_10_0516-bbadea11becb_add_name_and_size_to_tool_files.py b/api/migrations/versions/2024_10_10_0516-bbadea11becb_add_name_and_size_to_tool_files.py index 5b5656e7ed..00f2b15802 100644 --- a/api/migrations/versions/2024_10_10_0516-bbadea11becb_add_name_and_size_to_tool_files.py +++ b/api/migrations/versions/2024_10_10_0516-bbadea11becb_add_name_and_size_to_tool_files.py @@ -5,45 +5,61 @@ Revises: 33f5fac87f29 Create Date: 2024-10-10 05:16:14.764268 """ -from alembic import op -import models as models + import sqlalchemy as sa -from sqlalchemy.dialects import postgresql +from alembic import op, context # revision identifiers, used by Alembic. -revision = 'bbadea11becb' -down_revision = 'd8e744d88ed6' +revision = "bbadea11becb" +down_revision = "d8e744d88ed6" branch_labels = None depends_on = None def upgrade(): + def _has_name_or_size_column() -> bool: + # We cannot access the database in offline mode, so assume + # the "name" and "size" columns do not exist. + if context.is_offline_mode(): + # Log a warning message to inform the user that the database schema cannot be inspected + # in offline mode, and the generated SQL may not accurately reflect the actual execution. + op.execute( + "-- Executing in offline mode, assuming the name and size columns do not exist.\n" + "-- The generated SQL may differ from what will actually be executed.\n" + "-- Please review the migration script carefully!" + ) + + return False + # Use SQLAlchemy inspector to get the columns of the 'tool_files' table + inspector = sa.inspect(conn) + columns = [col["name"] for col in inspector.get_columns("tool_files")] + + # If 'name' or 'size' columns already exist, exit the upgrade function + if "name" in columns or "size" in columns: + return True + return False + # ### commands auto generated by Alembic - please adjust! ### # Get the database connection conn = op.get_bind() - # Use SQLAlchemy inspector to get the columns of the 'tool_files' table - inspector = sa.inspect(conn) - columns = [col['name'] for col in inspector.get_columns('tool_files')] - - # If 'name' or 'size' columns already exist, exit the upgrade function - if 'name' in columns or 'size' in columns: + if _has_name_or_size_column(): return - with op.batch_alter_table('tool_files', schema=None) as batch_op: - batch_op.add_column(sa.Column('name', sa.String(), nullable=True)) - batch_op.add_column(sa.Column('size', sa.Integer(), nullable=True)) + with op.batch_alter_table("tool_files", schema=None) as batch_op: + batch_op.add_column(sa.Column("name", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("size", sa.Integer(), nullable=True)) op.execute("UPDATE tool_files SET name = '' WHERE name IS NULL") op.execute("UPDATE tool_files SET size = -1 WHERE size IS NULL") - with op.batch_alter_table('tool_files', schema=None) as batch_op: - batch_op.alter_column('name', existing_type=sa.String(), nullable=False) - batch_op.alter_column('size', existing_type=sa.Integer(), nullable=False) + with op.batch_alter_table("tool_files", schema=None) as batch_op: + batch_op.alter_column("name", existing_type=sa.String(), nullable=False) + batch_op.alter_column("size", existing_type=sa.Integer(), nullable=False) # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - with op.batch_alter_table('tool_files', schema=None) as batch_op: - batch_op.drop_column('size') - batch_op.drop_column('name') + with op.batch_alter_table("tool_files", schema=None) as batch_op: + batch_op.drop_column("size") + batch_op.drop_column("name") # ### end Alembic commands ### diff --git a/api/migrations/versions/2024_12_23_1154-d7999dfa4aae_remove_workflow_node_executions_retry_.py b/api/migrations/versions/2024_12_23_1154-d7999dfa4aae_remove_workflow_node_executions_retry_.py index 07454b0917..adf6421e57 100644 --- a/api/migrations/versions/2024_12_23_1154-d7999dfa4aae_remove_workflow_node_executions_retry_.py +++ b/api/migrations/versions/2024_12_23_1154-d7999dfa4aae_remove_workflow_node_executions_retry_.py @@ -5,28 +5,38 @@ Revises: e1944c35e15e Create Date: 2024-12-23 11:54:15.344543 """ -from alembic import op -import models as models -import sqlalchemy as sa + +from alembic import op, context from sqlalchemy import inspect # revision identifiers, used by Alembic. -revision = 'd7999dfa4aae' -down_revision = 'e1944c35e15e' +revision = "d7999dfa4aae" +down_revision = "e1944c35e15e" branch_labels = None depends_on = None def upgrade(): - # Check if column exists before attempting to remove it - conn = op.get_bind() - inspector = inspect(conn) - has_column = 'retry_index' in [col['name'] for col in inspector.get_columns('workflow_node_executions')] + def _has_retry_index_column() -> bool: + if context.is_offline_mode(): + # Log a warning message to inform the user that the database schema cannot be inspected + # in offline mode, and the generated SQL may not accurately reflect the actual execution. + op.execute( + '-- Executing in offline mode: assuming the "retry_index" column does not exist.\n' + "-- The generated SQL may differ from what will actually be executed.\n" + "-- Please review the migration script carefully!" + ) + return False + conn = op.get_bind() + inspector = inspect(conn) + return "retry_index" in [col["name"] for col in inspector.get_columns("workflow_node_executions")] + + has_column = _has_retry_index_column() if has_column: - with op.batch_alter_table('workflow_node_executions', schema=None) as batch_op: - batch_op.drop_column('retry_index') + with op.batch_alter_table("workflow_node_executions", schema=None) as batch_op: + batch_op.drop_column("retry_index") def downgrade(): diff --git a/api/models/tools.py b/api/models/tools.py index 05604b9330..e027475e38 100644 --- a/api/models/tools.py +++ b/api/models/tools.py @@ -1,6 +1,6 @@ import json from datetime import datetime -from typing import Any, Optional, cast +from typing import Any, cast import sqlalchemy as sa from deprecated import deprecated @@ -304,8 +304,11 @@ class DeprecatedPublishedAppTool(Base): db.UniqueConstraint("app_id", "user_id", name="unique_published_app_tool"), ) + id = db.Column(StringUUID, server_default=db.text("uuid_generate_v4()")) # id of the app app_id = db.Column(StringUUID, ForeignKey("apps.id"), nullable=False) + + user_id: Mapped[str] = db.Column(StringUUID, nullable=False) # who published this tool description = db.Column(db.Text, nullable=False) # llm_description of the tool, for LLM @@ -325,34 +328,3 @@ class DeprecatedPublishedAppTool(Base): @property def description_i18n(self) -> I18nObject: return I18nObject(**json.loads(self.description)) - - id = db.Column(StringUUID, server_default=db.text("uuid_generate_v4()")) - user_id: Mapped[str] = db.Column(StringUUID, nullable=False) - tenant_id: Mapped[str] = db.Column(StringUUID, nullable=False) - conversation_id: Mapped[Optional[str]] = db.Column(StringUUID, nullable=True) - file_key: Mapped[str] = db.Column(db.String(255), nullable=False) - mimetype: Mapped[str] = db.Column(db.String(255), nullable=False) - original_url: Mapped[Optional[str]] = db.Column(db.String(2048), nullable=True) - name: Mapped[str] = mapped_column(default="") - size: Mapped[int] = mapped_column(default=-1) - - def __init__( - self, - *, - user_id: str, - tenant_id: str, - conversation_id: Optional[str] = None, - file_key: str, - mimetype: str, - original_url: Optional[str] = None, - name: str, - size: int, - ): - self.user_id = user_id - self.tenant_id = tenant_id - self.conversation_id = conversation_id - self.file_key = file_key - self.mimetype = mimetype - self.original_url = original_url - self.name = name - self.size = size From a6827493f0e9a4a2f8470c717e7f13f7b15c6f67 Mon Sep 17 00:00:00 2001 From: zxhlyh Date: Tue, 6 May 2025 18:24:10 +0800 Subject: [PATCH 2/2] chore: slice workflow refresh draft hook (#19292) --- .../workflow-app/components/workflow-main.tsx | 4 +++ .../components/workflow-app/hooks/index.ts | 1 + .../hooks/use-nodes-sync-draft.ts | 4 +-- .../hooks/use-workflow-refresh-draft.ts | 36 +++++++++++++++++++ .../components/workflow/hooks-store/store.ts | 3 ++ web/app/components/workflow/hooks/index.ts | 1 + .../hooks/use-workflow-interactions.ts | 25 ------------- .../hooks/use-workflow-refresh-draft.ts | 9 +++++ web/app/components/workflow/index.tsx | 4 +-- 9 files changed, 58 insertions(+), 29 deletions(-) create mode 100644 web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-refresh-draft.ts diff --git a/web/app/components/workflow-app/components/workflow-main.tsx b/web/app/components/workflow-app/components/workflow-main.tsx index 4ff1f4c624..2f2295cb59 100644 --- a/web/app/components/workflow-app/components/workflow-main.tsx +++ b/web/app/components/workflow-app/components/workflow-main.tsx @@ -8,6 +8,7 @@ import type { WorkflowProps } from '@/app/components/workflow' import WorkflowChildren from './workflow-children' import { useNodesSyncDraft, + useWorkflowRefreshDraft, useWorkflowRun, useWorkflowStartRun, } from '../hooks' @@ -32,6 +33,7 @@ const WorkflowMain = ({ doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose, } = useNodesSyncDraft() + const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() const { handleBackupDraft, handleLoadBackupDraft, @@ -49,6 +51,7 @@ const WorkflowMain = ({ return { syncWorkflowDraftWhenPageClose, doSyncWorkflowDraft, + handleRefreshWorkflowDraft, handleBackupDraft, handleLoadBackupDraft, handleRestoreFromPublishedWorkflow, @@ -61,6 +64,7 @@ const WorkflowMain = ({ }, [ syncWorkflowDraftWhenPageClose, doSyncWorkflowDraft, + handleRefreshWorkflowDraft, handleBackupDraft, handleLoadBackupDraft, handleRestoreFromPublishedWorkflow, diff --git a/web/app/components/workflow-app/hooks/index.ts b/web/app/components/workflow-app/hooks/index.ts index 1517eb9a16..6373a8591c 100644 --- a/web/app/components/workflow-app/hooks/index.ts +++ b/web/app/components/workflow-app/hooks/index.ts @@ -4,3 +4,4 @@ export * from './use-nodes-sync-draft' export * from './use-workflow-run' export * from './use-workflow-start-run' export * from './use-is-chat-mode' +export * from './use-workflow-refresh-draft' diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 7c6eb6a5be..db21cfb05e 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -6,20 +6,20 @@ import { useWorkflowStore, } from '@/app/components/workflow/store' import { BlockEnum } from '@/app/components/workflow/types' -import { useWorkflowUpdate } from '@/app/components/workflow/hooks' import { useNodesReadOnly, } from '@/app/components/workflow/hooks/use-workflow' import { syncWorkflowDraft } from '@/service/workflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { API_PREFIX } from '@/config' +import { useWorkflowRefreshDraft } from '.' export const useNodesSyncDraft = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() const featuresStore = useFeaturesStore() const { getNodesReadOnly } = useNodesReadOnly() - const { handleRefreshWorkflowDraft } = useWorkflowUpdate() + const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() const params = useParams() const getPostParams = useCallback(() => { diff --git a/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts new file mode 100644 index 0000000000..c944e10c4c --- /dev/null +++ b/web/app/components/workflow-app/hooks/use-workflow-refresh-draft.ts @@ -0,0 +1,36 @@ +import { useCallback } from 'react' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { fetchWorkflowDraft } from '@/service/workflow' +import type { WorkflowDataUpdater } from '@/app/components/workflow/types' +import { useWorkflowUpdate } from '@/app/components/workflow/hooks' + +export const useWorkflowRefreshDraft = () => { + const workflowStore = useWorkflowStore() + const { handleUpdateWorkflowCanvas } = useWorkflowUpdate() + + const handleRefreshWorkflowDraft = useCallback(() => { + const { + appId, + setSyncWorkflowDraftHash, + setIsSyncingWorkflowDraft, + setEnvironmentVariables, + setEnvSecrets, + setConversationVariables, + } = workflowStore.getState() + setIsSyncingWorkflowDraft(true) + fetchWorkflowDraft(`/apps/${appId}/workflows/draft`).then((response) => { + handleUpdateWorkflowCanvas(response.graph as WorkflowDataUpdater) + setSyncWorkflowDraftHash(response.hash) + setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { + acc[env.id] = env.value + return acc + }, {} as Record)) + setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) + setConversationVariables(response.conversation_variables || []) + }).finally(() => setIsSyncingWorkflowDraft(false)) + }, [handleUpdateWorkflowCanvas, workflowStore]) + + return { + handleRefreshWorkflowDraft, + } +} diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index 2e40cbfbc9..9f5e1a6650 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -18,6 +18,7 @@ type CommonHooksFnMap = { } ) => Promise syncWorkflowDraftWhenPageClose: () => void + handleRefreshWorkflowDraft: () => void handleBackupDraft: () => void handleLoadBackupDraft: () => void handleRestoreFromPublishedWorkflow: (...args: any[]) => void @@ -35,6 +36,7 @@ export type Shape = { export const createHooksStore = ({ doSyncWorkflowDraft = async () => noop(), syncWorkflowDraftWhenPageClose = noop, + handleRefreshWorkflowDraft = noop, handleBackupDraft = noop, handleLoadBackupDraft = noop, handleRestoreFromPublishedWorkflow = noop, @@ -48,6 +50,7 @@ export const createHooksStore = ({ refreshAll: props => set(state => ({ ...state, ...props })), doSyncWorkflowDraft, syncWorkflowDraftWhenPageClose, + handleRefreshWorkflowDraft, handleBackupDraft, handleLoadBackupDraft, handleRestoreFromPublishedWorkflow, diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index 20a34c69e3..fda0f50aa6 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -16,3 +16,4 @@ export * from './use-shortcuts' export * from './use-workflow-interactions' export * from './use-workflow-mode' export * from './use-format-time-from-now' +export * from './use-workflow-refresh-draft' diff --git a/web/app/components/workflow/hooks/use-workflow-interactions.ts b/web/app/components/workflow/hooks/use-workflow-interactions.ts index 740868c594..636d3b94f9 100644 --- a/web/app/components/workflow/hooks/use-workflow-interactions.ts +++ b/web/app/components/workflow/hooks/use-workflow-interactions.ts @@ -313,7 +313,6 @@ export const useWorkflowZoom = () => { export const useWorkflowUpdate = () => { const reactflow = useReactFlow() - const workflowStore = useWorkflowStore() const { eventEmitter } = useEventEmitterContextContext() const handleUpdateWorkflowCanvas = useCallback((payload: WorkflowDataUpdater) => { @@ -333,32 +332,8 @@ export const useWorkflowUpdate = () => { setViewport(viewport) }, [eventEmitter, reactflow]) - const handleRefreshWorkflowDraft = useCallback(() => { - const { - appId, - setSyncWorkflowDraftHash, - setIsSyncingWorkflowDraft, - setEnvironmentVariables, - setEnvSecrets, - setConversationVariables, - } = workflowStore.getState() - setIsSyncingWorkflowDraft(true) - fetchWorkflowDraft(`/apps/${appId}/workflows/draft`).then((response) => { - handleUpdateWorkflowCanvas(response.graph as WorkflowDataUpdater) - setSyncWorkflowDraftHash(response.hash) - setEnvSecrets((response.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { - acc[env.id] = env.value - return acc - }, {} as Record)) - setEnvironmentVariables(response.environment_variables?.map(env => env.value_type === 'secret' ? { ...env, value: '[__HIDDEN__]' } : env) || []) - // #TODO chatVar sync# - setConversationVariables(response.conversation_variables || []) - }).finally(() => setIsSyncingWorkflowDraft(false)) - }, [handleUpdateWorkflowCanvas, workflowStore]) - return { handleUpdateWorkflowCanvas, - handleRefreshWorkflowDraft, } } diff --git a/web/app/components/workflow/hooks/use-workflow-refresh-draft.ts b/web/app/components/workflow/hooks/use-workflow-refresh-draft.ts new file mode 100644 index 0000000000..1948bd471d --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-refresh-draft.ts @@ -0,0 +1,9 @@ +import { useHooksStore } from '@/app/components/workflow/hooks-store' + +export const useWorkflowRefreshDraft = () => { + const handleRefreshWorkflowDraft = useHooksStore(s => s.handleRefreshWorkflowDraft) + + return { + handleRefreshWorkflowDraft, + } +} diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 3d7692ed7b..549117faf7 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -44,7 +44,7 @@ import { useShortcuts, useWorkflow, useWorkflowReadOnly, - useWorkflowUpdate, + useWorkflowRefreshDraft, } from './hooks' import CustomNode from './nodes' import CustomNoteNode from './note-node' @@ -160,7 +160,7 @@ export const Workflow: FC = memo(({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - const { handleRefreshWorkflowDraft } = useWorkflowUpdate() + const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() const handleSyncWorkflowDraftWhenPageClose = useCallback(() => { if (document.visibilityState === 'hidden') syncWorkflowDraftWhenPageClose()