From 4e92004d62ac00b4755bd31d726db8f38dd20b6c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 Aug 2025 23:33:48 +0000 Subject: [PATCH 01/36] Add workflow management with SpiffWorkflow and basic frontend UI Co-authored-by: ann.jacob --- backend/app/api/v1/api.py | 3 +- backend/app/api/v1/endpoints/workflows.py | 50 +++++++++ backend/app/services/workflow_service.py | 117 ++++++++++++++++++++ backend/app/workflows/simple_approval.bpmn | 26 +++++ backend/requirements.txt | 3 +- frontend/src/App.vue | 9 +- frontend/src/components/WorkflowList.vue | 120 +++++++++++++++++++++ frontend/src/router/index.ts | 6 +- 8 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/v1/endpoints/workflows.py create mode 100644 backend/app/services/workflow_service.py create mode 100644 backend/app/workflows/simple_approval.bpmn create mode 100644 frontend/src/components/WorkflowList.vue diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 71262a8..7023ed1 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,7 +1,8 @@ from fastapi import APIRouter -from app.api.v1.endpoints import states +from app.api.v1.endpoints import states, workflows api_router = APIRouter() # Include all endpoint routers api_router.include_router(states.router, prefix="/states", tags=["states"]) +api_router.include_router(workflows.router, prefix="/workflows", tags=["workflows"]) diff --git a/backend/app/api/v1/endpoints/workflows.py b/backend/app/api/v1/endpoints/workflows.py new file mode 100644 index 0000000..9d942ab --- /dev/null +++ b/backend/app/api/v1/endpoints/workflows.py @@ -0,0 +1,50 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from typing import Any, Dict, List, Optional + +from app.services.workflow_service import WorkflowService + + +router = APIRouter() + + +class StartWorkflowRequest(BaseModel): + name: str = Field(default="Simple Approval") + bpmn_filename: str = Field(default="simple_approval.bpmn") + process_id: str = Field(default="SimpleApproval") + + +class CompleteTaskRequest(BaseModel): + data: Dict[str, Any] = Field(default_factory=dict) + + +@router.post("/start", summary="Start a new workflow instance") +async def start_workflow(payload: StartWorkflowRequest): + try: + return WorkflowService.start_workflow( + name=payload.name, + bpmn_filename=payload.bpmn_filename, + process_id=payload.process_id, + ) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="BPMN file not found") + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.get("/", summary="List workflow instances") +async def list_workflows(): + return WorkflowService.list_workflows() + + +@router.post("/{instance_id}/tasks/{task_id}/complete", summary="Complete a user task") +async def complete_task(instance_id: str, task_id: int, payload: CompleteTaskRequest): + try: + return WorkflowService.complete_user_task(instance_id, task_id, payload.data) + except KeyError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except Exception as exc: + raise HTTPException(status_code=500, detail=str(exc)) + diff --git a/backend/app/services/workflow_service.py b/backend/app/services/workflow_service.py new file mode 100644 index 0000000..bde445f --- /dev/null +++ b/backend/app/services/workflow_service.py @@ -0,0 +1,117 @@ +import threading +import uuid +from pathlib import Path +from typing import Dict, List, Optional + +from SpiffWorkflow.bpmn.parser import BpmnParser +from SpiffWorkflow.bpmn.workflow import BpmnWorkflow +from SpiffWorkflow.task import Task +from SpiffWorkflow.task_state import TaskState + + +class WorkflowInstance: + def __init__(self, instance_id: str, name: str, workflow: BpmnWorkflow): + self.instance_id = instance_id + self.name = name + self.workflow = workflow + + def to_dict(self) -> Dict: + return { + "id": self.instance_id, + "name": self.name, + "status": self._status(), + "ready_user_tasks": [ + { + "id": str(task.id), + "name": task.get_name(), + "spec_name": task.task_spec.name, + } + for task in WorkflowService.get_ready_user_tasks(self.workflow) + ], + } + + def _status(self) -> str: + if self.workflow.is_completed(): + return "COMPLETED" + if self.workflow.is_cancelled(): + return "CANCELLED" + return "RUNNING" + + +class WorkflowService: + _lock = threading.Lock() + _instances: Dict[str, WorkflowInstance] = {} + _parser: Optional[BpmnParser] = None + _spec_cache: Dict[str, object] = {} + + @staticmethod + def _get_parser() -> BpmnParser: + if WorkflowService._parser is None: + WorkflowService._parser = BpmnParser() + return WorkflowService._parser + + @staticmethod + def _get_bpmn_path(filename: str) -> Path: + base = Path(__file__).resolve().parent.parent / "workflows" + return base / filename + + @staticmethod + def load_spec(bpmn_filename: str, process_id: str): + cache_key = f"{bpmn_filename}::{process_id}" + if cache_key in WorkflowService._spec_cache: + return WorkflowService._spec_cache[cache_key] + parser = WorkflowService._get_parser() + path = WorkflowService._get_bpmn_path(bpmn_filename) + spec = parser.parse(str(path), process_id) + WorkflowService._spec_cache[cache_key] = spec + return spec + + @staticmethod + def start_workflow(name: str, bpmn_filename: str, process_id: str) -> Dict: + spec = WorkflowService.load_spec(bpmn_filename, process_id) + workflow = BpmnWorkflow(spec) + workflow.do_engine_steps() + instance_id = str(uuid.uuid4()) + instance = WorkflowInstance(instance_id, name, workflow) + with WorkflowService._lock: + WorkflowService._instances[instance_id] = instance + return instance.to_dict() + + @staticmethod + def list_workflows() -> List[Dict]: + with WorkflowService._lock: + return [instance.to_dict() for instance in WorkflowService._instances.values()] + + @staticmethod + def get_instance(instance_id: str) -> WorkflowInstance: + with WorkflowService._lock: + instance = WorkflowService._instances.get(instance_id) + if instance is None: + raise KeyError("Workflow instance not found") + return instance + + @staticmethod + def get_ready_user_tasks(workflow: BpmnWorkflow) -> List[Task]: + tasks: List[Task] = workflow.get_tasks(state=TaskState.READY) + def is_user_task(t: Task) -> bool: + name = getattr(t.task_spec, "bpmn_name", "") + if isinstance(name, str) and name.lower() == "usertask": + return True + # Fallback to class name heuristic + return "user" in t.task_spec.__class__.__name__.lower() + return [t for t in tasks if is_user_task(t)] + + @staticmethod + def complete_user_task(instance_id: str, task_id: int, data: Dict) -> Dict: + instance = WorkflowService.get_instance(instance_id) + workflow = instance.workflow + task: Optional[Task] = next((t for t in workflow.get_tasks() if t.id == task_id), None) + if task is None: + raise KeyError("Task not found") + if task.state != TaskState.READY: + raise ValueError("Task is not ready") + task.data.update(data or {}) + workflow.complete_task_from_id(task_id) + workflow.do_engine_steps() + return instance.to_dict() + diff --git a/backend/app/workflows/simple_approval.bpmn b/backend/app/workflows/simple_approval.bpmn new file mode 100644 index 0000000..8396447 --- /dev/null +++ b/backend/app/workflows/simple_approval.bpmn @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/backend/requirements.txt b/backend/requirements.txt index 57004d2..114d8eb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,4 +7,5 @@ sqlalchemy==2.0.23 psycopg2-binary==2.9.9 alembic==1.12.1 pytest==7.4.2 -flake8==7.0.0 \ No newline at end of file +flake8==7.0.0 +SpiffWorkflow \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 8c9655e..a7ee05a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e416ec6..b3d8803 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,8 +2,12 @@ import { createRouter, createWebHashHistory } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router'; import HelloWorld from '../components/HelloWorld.vue'; +import WorkflowList from '../components/WorkflowList.vue'; -const routes: Array = [{ path: '/', name: 'home', component: HelloWorld }]; +const routes: Array = [ + { path: '/', name: 'home', component: HelloWorld }, + { path: '/workflows', name: 'workflows', component: WorkflowList }, +]; export const router = createRouter({ history: createWebHashHistory(), From 9effddcb40d7245fb77d5cc05d688948a8adf668 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 Aug 2025 23:44:20 +0000 Subject: [PATCH 02/36] Move WorkflowList component from components to view directory Co-authored-by: ann.jacob --- frontend/src/router/index.ts | 2 +- frontend/src/{components => view}/WorkflowList.vue | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename frontend/src/{components => view}/WorkflowList.vue (100%) diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b3d8803..9946ab2 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,7 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'; import type { RouteRecordRaw } from 'vue-router'; import HelloWorld from '../components/HelloWorld.vue'; -import WorkflowList from '../components/WorkflowList.vue'; +import WorkflowList from '../view/WorkflowList.vue'; const routes: Array = [ { path: '/', name: 'home', component: HelloWorld }, diff --git a/frontend/src/components/WorkflowList.vue b/frontend/src/view/WorkflowList.vue similarity index 100% rename from frontend/src/components/WorkflowList.vue rename to frontend/src/view/WorkflowList.vue From fa7c2c7c3a2008a4841a23aa6289472008455c73 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 Aug 2025 23:52:02 +0000 Subject: [PATCH 03/36] Checkpoint before follow-up message Co-authored-by: ann.jacob --- frontend/src/constants/index.ts | 2 ++ frontend/src/services/http.ts | 22 +++++++++++++++++ frontend/src/services/workflows.ts | 22 +++++++++++++++++ frontend/src/types/workflow.ts | 13 ++++++++++ frontend/src/view/WorkflowList.vue | 39 +++++++----------------------- 5 files changed, 68 insertions(+), 30 deletions(-) create mode 100644 frontend/src/constants/index.ts create mode 100644 frontend/src/services/http.ts create mode 100644 frontend/src/services/workflows.ts create mode 100644 frontend/src/types/workflow.ts diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts new file mode 100644 index 0000000..e9860ac --- /dev/null +++ b/frontend/src/constants/index.ts @@ -0,0 +1,2 @@ +export const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8300/api/v1'; + diff --git a/frontend/src/services/http.ts b/frontend/src/services/http.ts new file mode 100644 index 0000000..1fb25a1 --- /dev/null +++ b/frontend/src/services/http.ts @@ -0,0 +1,22 @@ +import { API_BASE } from '../constants'; + +type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + +export async function http(path: string, options?: { method?: HttpMethod; body?: unknown; headers?: Record }): Promise { + const { method = 'GET', body, headers = {} } = options ?? {}; + const res = await fetch(`${API_BASE}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ''}`); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + diff --git a/frontend/src/services/workflows.ts b/frontend/src/services/workflows.ts new file mode 100644 index 0000000..582816c --- /dev/null +++ b/frontend/src/services/workflows.ts @@ -0,0 +1,22 @@ +import { http } from './http'; +import type { WorkflowInstance } from '../types/workflow'; + +export async function startWorkflow(name = 'Simple Approval'): Promise { + return http('/workflows/start', { method: 'POST', body: { name } }); +} + +export async function listWorkflows(): Promise { + return http('/workflows/'); +} + +export async function completeUserTask( + instanceId: string, + taskId: string, + data: Record, +): Promise { + return http(`/workflows/${instanceId}/tasks/${taskId}/complete`, { + method: 'POST', + body: { data }, + }); +} + diff --git a/frontend/src/types/workflow.ts b/frontend/src/types/workflow.ts new file mode 100644 index 0000000..0538581 --- /dev/null +++ b/frontend/src/types/workflow.ts @@ -0,0 +1,13 @@ +export type TaskSummary = { + id: string; + name: string; + spec_name: string; +}; + +export type WorkflowInstance = { + id: string; + name: string; + status: string; + ready_user_tasks: TaskSummary[]; +}; + diff --git a/frontend/src/view/WorkflowList.vue b/frontend/src/view/WorkflowList.vue index d6d3fb4..84dbc4a 100644 --- a/frontend/src/view/WorkflowList.vue +++ b/frontend/src/view/WorkflowList.vue @@ -1,20 +1,11 @@