DocWeave / backend /app /api /workflows.py
shak3008's picture
fix: resolve workflow linking on resume, update proposals auto-commit, and validation baseline rules
90c285f
Raw
History Blame Contribute Delete
10.3 kB
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload, selectinload
from app.core.dependencies import get_current_user
from app.database.session import get_db
from app.models.document_version import DocumentVersion
from app.models.user import User
from app.models.workflow_run import WorkflowRun, WorkflowStatus
from app.models.workspace import Workspace
router = APIRouter(
prefix="/workflows",
tags=["Workflows"],
)
def _workflow_response(workflow: WorkflowRun) -> dict:
dv = workflow.document_version
# Determine completed stages from checkpoints
checkpoints = workflow.checkpoints or []
completed_stages = [cp.agent_name for cp in checkpoints]
# Infer current node from status + checkpoints
if workflow.status == WorkflowStatus.COMPLETED:
current_node = "COMPLETED"
elif workflow.status == WorkflowStatus.WAITING_FOR_REVIEW:
current_node = "HUMAN_REVIEW"
elif workflow.status == WorkflowStatus.FAILED:
current_node = "FAILED"
elif workflow.status == WorkflowStatus.CANCELLED:
current_node = "CANCELLED"
elif workflow.status == WorkflowStatus.PENDING:
current_node = "PENDING"
else:
# RUNNING — read live progress from PROGRESS checkpoint
progress_cp = None
for cp in reversed(checkpoints):
if cp.agent_name == "PROGRESS":
progress_cp = cp
break
if progress_cp and progress_cp.state:
current_node = progress_cp.state.get("current_node", "EXTRACTION")
else:
current_node = "EXTRACTION"
# Get validation results from checkpoint state if available
validation_results = None
for cp in reversed(checkpoints):
if cp.state and "validation_results" in cp.state:
validation_results = cp.state["validation_results"]
break
return {
"id": str(workflow.id),
"workspace_id": str(workflow.workspace_id),
"document_version_id": str(workflow.document_version_id),
"document_id": str(dv.document_id) if dv else None,
"filename": dv.filename if dv else None,
"status": workflow.status.value,
"current_node": current_node,
"completed_stages": completed_stages,
"validation_results": validation_results,
"started_at": workflow.started_at,
"completed_at": workflow.completed_at,
}
def _sync_workflow_review_state(workflow: WorkflowRun, db: Session) -> None:
"""
If a workflow is WAITING_FOR_REVIEW but has no PENDING proposals left
(all proposals were approved, rejected, or archived), resume/complete the workflow.
"""
if workflow.status == WorkflowStatus.WAITING_FOR_REVIEW and workflow.document_version_id:
from app.services.proposal_review_service import ProposalReviewService
service = ProposalReviewService(db)
pending = service.get_pending_for_document_version(
workflow.workspace_id,
workflow.document_version_id,
)
if not pending:
from app.workflow import WorkflowExecutor
from datetime import datetime, timezone
try:
executor = WorkflowExecutor(db)
try:
executor.resume(workflow)
finally:
executor.close()
except Exception:
workflow.status = WorkflowStatus.FAILED
if not workflow.completed_at:
workflow.completed_at = datetime.now(timezone.utc)
db.commit()
@router.get("")
def list_workflows(
workspace_id: UUID,
status: str | None = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
workspace = (
db.query(Workspace)
.filter(
Workspace.id == workspace_id,
Workspace.created_by == current_user.id,
)
.first()
)
if workspace is None:
raise HTTPException(
status_code=403,
detail="You do not have access to this workspace.",
)
query = (
db.query(WorkflowRun)
.options(
joinedload(WorkflowRun.document_version),
selectinload(WorkflowRun.checkpoints),
)
.filter(WorkflowRun.workspace_id == workspace_id)
)
if status:
try:
ws = WorkflowStatus(status.upper())
query = query.filter(WorkflowRun.status == ws)
except ValueError:
pass
workflows = query.order_by(WorkflowRun.started_at.desc()).all()
for w in workflows:
_sync_workflow_review_state(w, db)
return [_workflow_response(w) for w in workflows]
@router.get("/by-document-version/{document_version_id}")
def get_workflow_by_document_version(
document_version_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
workflow = (
db.query(WorkflowRun)
.options(
joinedload(WorkflowRun.document_version),
selectinload(WorkflowRun.checkpoints),
)
.filter(WorkflowRun.document_version_id == document_version_id)
.order_by(WorkflowRun.started_at.desc())
.first()
)
if workflow is None:
raise HTTPException(status_code=404, detail="No workflow found for this document version.")
workspace = (
db.query(Workspace)
.filter(
Workspace.id == workflow.workspace_id,
Workspace.created_by == current_user.id,
)
.first()
)
if workspace is None:
raise HTTPException(status_code=403, detail="You do not have access to this workflow.")
# Auto-detect stuck workflows: if RUNNING for >5 min with no progress, mark as FAILED
if workflow.status == WorkflowStatus.RUNNING:
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
started = workflow.started_at.replace(tzinfo=timezone.utc) if workflow.started_at.tzinfo is None else workflow.started_at
if now - started > timedelta(minutes=5):
# Check if there's been recent progress
latest_checkpoint = None
for cp in reversed(workflow.checkpoints or []):
if cp.agent_name == "PROGRESS":
latest_checkpoint = cp
break
stale = True
if latest_checkpoint and latest_checkpoint.created_at:
cp_time = latest_checkpoint.created_at.replace(tzinfo=timezone.utc) if latest_checkpoint.created_at.tzinfo is None else latest_checkpoint.created_at
if now - cp_time < timedelta(minutes=5):
stale = False
if stale:
workflow.status = WorkflowStatus.FAILED
db.commit()
_sync_workflow_review_state(workflow, db)
return _workflow_response(workflow)
@router.get("/{workflow_id}")
def get_workflow(
workflow_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
workflow = (
db.query(WorkflowRun)
.options(
joinedload(WorkflowRun.document_version),
selectinload(WorkflowRun.checkpoints),
)
.filter(WorkflowRun.id == workflow_id)
.first()
)
if workflow is None:
raise HTTPException(
status_code=404,
detail="Workflow not found.",
)
_sync_workflow_review_state(workflow, db)
workspace = (
db.query(Workspace)
.filter(
Workspace.id == workflow.workspace_id,
Workspace.created_by == current_user.id,
)
.first()
)
if workspace is None:
raise HTTPException(
status_code=403,
detail="You do not have access to this workflow.",
)
# Auto-detect stuck workflows: if RUNNING for >5 min with no progress, mark as FAILED
if workflow.status == WorkflowStatus.RUNNING:
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
started = workflow.started_at.replace(tzinfo=timezone.utc) if workflow.started_at.tzinfo is None else workflow.started_at
if now - started > timedelta(minutes=5):
# Check if there's been recent progress
latest_checkpoint = None
for cp in reversed(workflow.checkpoints or []):
if cp.agent_name == "PROGRESS":
latest_checkpoint = cp
break
stale = True
if latest_checkpoint and latest_checkpoint.created_at:
cp_time = latest_checkpoint.created_at.replace(tzinfo=timezone.utc) if latest_checkpoint.created_at.tzinfo is None else latest_checkpoint.created_at
if now - cp_time < timedelta(minutes=5):
stale = False
if stale:
workflow.status = WorkflowStatus.FAILED
db.commit()
return _workflow_response(workflow)
@router.post("/{workflow_id}/cancel", status_code=200)
def cancel_workflow(
workflow_id: UUID,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Mark a stuck/running workflow as FAILED so it stops polling."""
workflow = (
db.query(WorkflowRun)
.filter(WorkflowRun.id == workflow_id)
.first()
)
if workflow is None:
raise HTTPException(status_code=404, detail="Workflow not found.")
workspace = (
db.query(Workspace)
.filter(Workspace.id == workflow.workspace_id, Workspace.created_by == current_user.id)
.first()
)
if workspace is None:
raise HTTPException(status_code=403, detail="You do not have access to this workflow.")
if workflow.status in (WorkflowStatus.COMPLETED, WorkflowStatus.CANCELLED):
return {"id": str(workflow.id), "status": workflow.status.value, "message": "Already terminal."}
workflow.status = WorkflowStatus.FAILED
db.commit()
return {"id": str(workflow.id), "status": "FAILED", "message": "Workflow marked as failed."}