diff --git a/backend/alembic/versions/072_workflow_definition_updated_at.py b/backend/alembic/versions/072_workflow_definition_updated_at.py new file mode 100644 index 0000000..1f4b1bc --- /dev/null +++ b/backend/alembic/versions/072_workflow_definition_updated_at.py @@ -0,0 +1,28 @@ +"""add updated_at to workflow_definitions + +Revision ID: 072 +Revises: 071 +""" +from alembic import op +import sqlalchemy as sa + +revision = "072" +down_revision = "071" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "workflow_definitions", + sa.Column( + "updated_at", + sa.DateTime(), + nullable=False, + server_default=sa.text("now()"), + ), + ) + + +def downgrade() -> None: + op.drop_column("workflow_definitions", "updated_at") diff --git a/backend/app/domains/rendering/dispatch_service.py b/backend/app/domains/rendering/dispatch_service.py index f717745..98c1340 100644 --- a/backend/app/domains/rendering/dispatch_service.py +++ b/backend/app/domains/rendering/dispatch_service.py @@ -705,7 +705,7 @@ def dispatch_render_with_workflow(order_line_id: str) -> dict: def _legacy_dispatch(order_line_id: str) -> dict: - """Queue render_order_line_task (the working Celery render implementation).""" - from app.tasks.step_tasks import render_order_line_task - render_order_line_task.delay(order_line_id) + """Queue via dispatch_order_line_render so cancelled/rejected pre-checks apply.""" + from app.tasks.step_tasks import dispatch_order_line_render + dispatch_order_line_render.delay(order_line_id) return {"backend": "celery", "queued": True} diff --git a/backend/app/domains/rendering/models.py b/backend/app/domains/rendering/models.py index 1e6f5e9..7b2b3e2 100644 --- a/backend/app/domains/rendering/models.py +++ b/backend/app/domains/rendering/models.py @@ -166,6 +166,7 @@ class WorkflowDefinition(Base): config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) runs: Mapped[list["WorkflowRun"]] = relationship( "WorkflowRun", back_populates="workflow_def", lazy="noload", cascade="all, delete-orphan" diff --git a/backend/app/domains/rendering/schemas.py b/backend/app/domains/rendering/schemas.py index 5bc4463..27e91e1 100644 --- a/backend/app/domains/rendering/schemas.py +++ b/backend/app/domains/rendering/schemas.py @@ -193,6 +193,7 @@ class WorkflowDefinitionUpdate(BaseModel): name: str | None = None config: dict | None = None is_active: bool | None = None + updated_at: datetime | None = None class WorkflowDefinitionOut(BaseModel): @@ -207,6 +208,7 @@ class WorkflowDefinitionOut(BaseModel): ) is_active: bool created_at: datetime + updated_at: datetime model_config = {"from_attributes": True} diff --git a/backend/app/domains/rendering/workflow_router.py b/backend/app/domains/rendering/workflow_router.py index 43fdeef..2ad1f3f 100644 --- a/backend/app/domains/rendering/workflow_router.py +++ b/backend/app/domains/rendering/workflow_router.py @@ -284,6 +284,7 @@ async def _workflow_to_out(db: AsyncSession, wf: WorkflowDefinition) -> Workflow ), is_active=wf.is_active, created_at=wf.created_at, + updated_at=wf.updated_at, ) @@ -938,6 +939,15 @@ async def update_workflow( if not wf: raise HTTPException(status_code=404, detail="Workflow definition not found") + if body.updated_at is not None: + stored_ts = wf.updated_at.replace(tzinfo=None) if wf.updated_at.tzinfo else wf.updated_at + client_ts = body.updated_at.replace(tzinfo=None) if body.updated_at.tzinfo else body.updated_at + if abs((stored_ts - client_ts).total_seconds()) > 1: + raise HTTPException( + status_code=409, + detail="Workflow was modified by someone else. Reload and try again.", + ) + if body.name is not None: wf.name = body.name if body.config is not None: diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index b9e7e08..3d72da5 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -54,6 +54,7 @@ export interface WorkflowDefinition { rollout_summary: WorkflowRolloutSummary is_active: boolean created_at: string + updated_at: string } export interface WorkflowConfig { @@ -239,7 +240,11 @@ export const getWorkflow = (id: string): Promise => export const createWorkflow = (data: WorkflowCreate): Promise => api.post('/workflows', data).then(r => normalizeWorkflowDefinition(r.data)) -export const updateWorkflow = (id: string, data: Partial): Promise => +export interface WorkflowUpdatePayload extends Partial { + updated_at?: string +} + +export const updateWorkflow = (id: string, data: WorkflowUpdatePayload): Promise => api.put(`/workflows/${id}`, data).then(r => normalizeWorkflowDefinition(r.data)) export const deleteWorkflow = (id: string): Promise => diff --git a/frontend/src/components/workflows/WorkflowCanvas.tsx b/frontend/src/components/workflows/WorkflowCanvas.tsx index 90cfb26..3560558 100644 --- a/frontend/src/components/workflows/WorkflowCanvas.tsx +++ b/frontend/src/components/workflows/WorkflowCanvas.tsx @@ -56,9 +56,10 @@ type WorkflowCanvasProps = { workflow: WorkflowDefinition onSave: (config: WorkflowConfig) => void isSaving: boolean + canSave?: boolean } -export function WorkflowCanvas({ workflow, onSave, isSaving }: WorkflowCanvasProps) { +export function WorkflowCanvas({ workflow, onSave, isSaving, canSave = true }: WorkflowCanvasProps) { const queryClient = useQueryClient() const { reactFlowWrapper, @@ -308,6 +309,7 @@ export function WorkflowCanvas({ workflow, onSave, isSaving }: WorkflowCanvasPro isDispatchPending={dispatchMutation.isPending} isContextOptionsLoading={isOrderLineContextsLoading} isSaving={isSaving} + canSave={canSave} rollbackPendingOutputTypeId={rollbackOutputTypeMutation.variables?.outputTypeId ?? null} preflightState={preflightState} authoringActions={authoringActions} diff --git a/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx b/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx index 8f33da6..d0a4b1b 100644 --- a/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx +++ b/frontend/src/components/workflows/WorkflowCanvasToolbar.tsx @@ -125,6 +125,7 @@ interface WorkflowCanvasToolbarProps { isDispatchPending: boolean isContextOptionsLoading: boolean isSaving: boolean + canSave?: boolean rollbackPendingOutputTypeId?: string | null preflightState: 'ready' | 'required' | 'stale' | 'blocked' authoringActions: WorkflowAuthoringActions @@ -175,6 +176,7 @@ export function WorkflowCanvasToolbar({ isDispatchPending, isContextOptionsLoading, isSaving, + canSave = true, rollbackPendingOutputTypeId, preflightState, authoringActions, @@ -345,7 +347,8 @@ export function WorkflowCanvasToolbar({ diff --git a/frontend/src/pages/WorkflowEditor.tsx b/frontend/src/pages/WorkflowEditor.tsx index 7b485f9..2736625 100644 --- a/frontend/src/pages/WorkflowEditor.tsx +++ b/frontend/src/pages/WorkflowEditor.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useAuthStore, isAdmin } from '../store/auth' import { WorkflowCanvas } from '../components/workflows/WorkflowCanvas' import { NewWorkflowModal } from '../components/workflows/NewWorkflowModal' import { WorkflowEditorEmptyState } from '../components/workflows/WorkflowEditorEmptyState' @@ -39,6 +40,8 @@ export default function WorkflowEditor() { const queryClient = useQueryClient() const [selectedId, setSelectedId] = useState(null) const [showNewModal, setShowNewModal] = useState(false) + const user = useAuthStore(s => s.user) + const canEdit = isAdmin(user) const { data: workflows = [], isLoading } = useQuery({ queryKey: ['workflows'], @@ -67,13 +70,19 @@ export default function WorkflowEditor() { }) const updateMutation = useMutation({ - mutationFn: ({ id, config }: { id: string; config: WorkflowConfig }) => - updateWorkflow(id, { config }), + mutationFn: ({ id, config, updated_at }: { id: string; config: WorkflowConfig; updated_at?: string }) => + updateWorkflow(id, { config, updated_at }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workflows'] }) toast.success('Workflow saved') }, - onError: () => toast.error('Failed to save workflow'), + onError: (err: any) => { + if (err?.response?.status === 409) { + toast.error('Workflow was modified by someone else — please reload the page') + } else { + toast.error('Failed to save workflow') + } + }, }) const deleteMutation = useMutation({ @@ -199,8 +208,9 @@ export default function WorkflowEditor() { updateMutation.mutate({ id: selectedWorkflow.id, config })} + onSave={config => updateMutation.mutate({ id: selectedWorkflow.id, config, updated_at: selectedWorkflow.updated_at })} isSaving={updateMutation.isPending} + canSave={canEdit} /> ) : (