diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 7b97781e..ac0c6a86 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -1817,6 +1817,27 @@ paths: application/json: schema: $ref: "#/components/schemas/ServiceTask" + /tasks/{process_instance_id}: + parameters: + - name: process_instance_id + in: path + required: true + description: The unique id of an existing process instance. + schema: + type: integer + get: + tags: + - Tasks + operationId: spiffworkflow_backend.routes.tasks_controller.interstitial + summary: An SSE (Server Sent Events) endpoint that returns what tasks are currently active (running, waiting, or the final END event) + responses: + "200": + description: One task + content: + application/json: + schema: + $ref: "#/components/schemas/Task" + /tasks/{process_instance_id}/{task_guid}: parameters: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py index 5b35df3e..922ae456 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py @@ -205,7 +205,6 @@ class ProcessInstanceApi: next_task: Task | None, process_model_identifier: str, process_model_display_name: str, - completed_tasks: int, updated_at_in_seconds: int, ) -> None: """__init__.""" @@ -214,7 +213,6 @@ class ProcessInstanceApi: self.next_task = next_task # The next task that requires user input. self.process_model_identifier = process_model_identifier self.process_model_display_name = process_model_display_name - self.completed_tasks = completed_tasks self.updated_at_in_seconds = updated_at_in_seconds diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py index 9baffd25..4486d91a 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py @@ -1,7 +1,9 @@ """APIs for dealing with process groups, process models, and process instances.""" import json import os +import time import uuid +from datetime import datetime from sys import exc_info from typing import Any from typing import Dict @@ -12,7 +14,7 @@ from typing import Union import flask.wrappers import jinja2 import sentry_sdk -from flask import current_app +from flask import current_app, stream_with_context from flask import g from flask import jsonify from flask import make_response @@ -262,7 +264,7 @@ def manual_complete_task( ) -def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Response: +def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappers.Response: """Task_show.""" process_instance = _find_process_instance_by_id_or_raise(process_instance_id) @@ -277,12 +279,16 @@ def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Respon process_instance.process_model_identifier, ) - _find_human_task_or_raise(process_instance_id, task_guid) + # _find_human_task_or_raise(process_instance_id, task_guid) form_schema_file_name = "" form_ui_schema_file_name = "" processor = ProcessInstanceProcessor(process_instance) - spiff_task = _get_spiff_task_from_process_instance(task_guid, process_instance, processor=processor) + if task_guid == "next": + spiff_task = processor.next_task() + task_guid = spiff_task.id + else: + spiff_task = _get_spiff_task_from_process_instance(task_guid, process_instance, processor=processor) extensions = spiff_task.task_spec.extensions if "properties" in extensions: @@ -344,7 +350,11 @@ def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Respon task.form_ui_schema = ui_form_contents _munge_form_ui_schema_based_on_hidden_fields_in_task_data(task) + _render_instructions_for_end_user(spiff_task, task) + return make_response(jsonify(task), 200) +def _render_instructions_for_end_user(spiff_task: SpiffTask, task: Task): + """Assure any instructions for end user are processed for jinja syntax.""" if task.properties and "instructionsForEndUser" in task.properties: if task.properties["instructionsForEndUser"]: try: @@ -354,7 +364,7 @@ def task_show(process_instance_id: int, task_guid: str) -> flask.wrappers.Respon except WorkflowTaskException as wfe: wfe.add_note("Failed to render instructions for end user.") raise ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe) from wfe - return make_response(jsonify(task), 200) + return "" def process_data_show( @@ -380,6 +390,24 @@ def process_data_show( 200, ) +def interstitial(process_instance_id: int): + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) + processor = ProcessInstanceProcessor(process_instance) + + def get_data(): + spiff_task = processor.next_task() + last_task = None + while last_task != spiff_task: + task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task()) + _render_instructions_for_end_user(spiff_task, task) + yield f'data: {current_app.json.dumps(task)} \n\n' + last_task = spiff_task + processor.do_engine_steps(execution_strategy_name="one_at_a_time") + spiff_task = processor.next_task() + return + + # return Response(get_data(), mimetype='text/event-stream') + return Response(stream_with_context(get_data()), mimetype='text/event-stream') def _task_submit_shared( process_instance_id: int, @@ -462,9 +490,15 @@ def _task_submit_shared( ) if next_human_task_assigned_to_me: return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200) + elif processor.next_task(): + task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task()) + return make_response(jsonify(task), 200) - return Response(json.dumps({"ok": True}), status=202, mimetype="application/json") - + return Response(json.dumps( + {"ok": True, + "process_model_identifier": process_instance.process_model_identifier, + "process_instance_id": process_instance_id + }), status=202, mimetype="application/json") def task_submit( process_instance_id: int, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py index 885dda50..3527e1a3 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py @@ -1642,7 +1642,7 @@ class ProcessInstanceProcessor: self.save, ) try: - execution_service.run(exit_at, save) + execution_service.run_and_save(exit_at, save) finally: # clear out failling spiff tasks here since the ProcessInstanceProcessor creates an instance of the # script engine on a class variable. diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py index 3a0307f1..98ef7c4d 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -155,7 +155,6 @@ class ProcessInstanceService: next_task=None, process_model_identifier=processor.process_model_identifier, process_model_display_name=processor.process_model_display_name, - completed_tasks=processor.process_instance_model.completed_tasks, updated_at_in_seconds=processor.process_instance_model.updated_at_in_seconds, ) @@ -442,6 +441,8 @@ class ProcessInstanceService: spiff_task.get_state_name(), lane=lane, process_identifier=spiff_task.task_spec._wf_spec.name, + process_instance_id=processor.process_instance_model.id, + process_model_identifier=processor.process_model_identifier, properties=props, parent=parent_id, event_definition=serialized_task_spec.get("event_definition"), diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py index 5764cc89..4ef3e013 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_execution_service.py @@ -1,6 +1,6 @@ import copy import time -from typing import Callable +from typing import Callable, List from typing import Optional from typing import Set from uuid import UUID @@ -206,6 +206,15 @@ class ExecutionStrategy: def save(self, bpmn_process_instance: BpmnWorkflow) -> None: self.delegate.save(bpmn_process_instance) + def get_ready_engine_steps(self, bpmn_process_instance: BpmnWorkflow) -> List[SpiffTask]: + return list( + [ + t + for t in bpmn_process_instance.get_tasks(TaskState.READY) + if bpmn_process_instance._is_engine_task(t.task_spec) + ] + ) + class GreedyExecutionStrategy(ExecutionStrategy): """The common execution strategy. This will greedily run all engine steps without stopping.""" @@ -234,7 +243,6 @@ class GreedyExecutionStrategy(ExecutionStrategy): if non_human_waiting_task is not None: self.run_until_user_input_required(exit_at) - class RunUntilServiceTaskExecutionStrategy(ExecutionStrategy): """For illustration purposes, not currently integrated. @@ -243,30 +251,45 @@ class RunUntilServiceTaskExecutionStrategy(ExecutionStrategy): """ def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None: - self.bpmn_process_instance = bpmn_process_instance - engine_steps = list( - [ - t - for t in bpmn_process_instance.get_tasks(TaskState.READY) - if bpmn_process_instance._is_engine_task(t.task_spec) - ] - ) + engine_steps = self.get_ready_engine_steps(bpmn_process_instance) while engine_steps: for spiff_task in engine_steps: if spiff_task.task_spec.spec_type == "Service Task": return self.delegate.will_complete_task(spiff_task) - spiff_task.complete() + spiff_task.run() self.delegate.did_complete_task(spiff_task) + bpmn_process_instance.refresh_waiting_tasks() + engine_steps = self.get_ready_engine_steps(bpmn_process_instance) + self.delegate.after_engine_steps(bpmn_process_instance) - engine_steps = list( - [ - t - for t in bpmn_process_instance.get_tasks(TaskState.READY) - if bpmn_process_instance._is_engine_task(t.task_spec) - ] - ) +class RunUntilUserMessageExecutionStrategy(ExecutionStrategy): + """When you want to run tasks until you hit something to report to the end user, or + until there are no other engine steps to complete.""" + + def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None: + engine_steps = self.get_ready_engine_steps(bpmn_process_instance) + while engine_steps: + for spiff_task in engine_steps: + self.delegate.will_complete_task(spiff_task) + spiff_task.run() + self.delegate.did_complete_task(spiff_task) + if spiff_task.task_spec.properties.get("instructionsForEndUser", None) is not None: + break + engine_steps = self.get_ready_engine_steps(bpmn_process_instance) + self.delegate.after_engine_steps(bpmn_process_instance) + +class OneAtATimeExecutionStrategy(ExecutionStrategy): + """When you want to run only one engine step at a time.""" + + def spiff_run(self, bpmn_process_instance: BpmnWorkflow, exit_at: None = None) -> None: + engine_steps = self.get_ready_engine_steps(bpmn_process_instance) + if len(engine_steps) > 0: + spiff_task = engine_steps[0] + self.delegate.will_complete_task(spiff_task) + spiff_task.run() + self.delegate.did_complete_task(spiff_task) self.delegate.after_engine_steps(bpmn_process_instance) @@ -274,6 +297,8 @@ def execution_strategy_named(name: str, delegate: EngineStepDelegate) -> Executi cls = { "greedy": GreedyExecutionStrategy, "run_until_service_task": RunUntilServiceTaskExecutionStrategy, + "run_until_user_message": RunUntilUserMessageExecutionStrategy, + "one_at_a_time": OneAtATimeExecutionStrategy, }[name] return cls(delegate) @@ -282,7 +307,6 @@ def execution_strategy_named(name: str, delegate: EngineStepDelegate) -> Executi ProcessInstanceCompleter = Callable[[BpmnWorkflow], None] ProcessInstanceSaver = Callable[[], None] - class WorkflowExecutionService: """Provides the driver code for workflow execution.""" @@ -306,16 +330,7 @@ class WorkflowExecutionService: # run # execution_strategy.spiff_run # spiff.[some_run_task_method] - def run(self, exit_at: None = None, save: bool = False) -> None: - """Do_engine_steps.""" - with safe_assertion(ProcessInstanceLockService.has_lock(self.process_instance_model.id)) as tripped: - if tripped: - raise AssertionError( - "The current thread has not obtained a lock for this process" - f" instance ({self.process_instance_model.id})." - ) - - try: + def run_and_save(self, exit_at: None = None, save: bool = False) -> None: self.bpmn_process_instance.refresh_waiting_tasks() # TODO: implicit re-entrant locks here `with_dequeued` @@ -324,17 +339,9 @@ class WorkflowExecutionService: if self.bpmn_process_instance.is_completed(): self.process_instance_completer(self.bpmn_process_instance) - self.process_bpmn_messages() - self.queue_waiting_receive_messages() - except SpiffWorkflowException as swe: - raise ApiError.from_workflow_exception("task_error", str(swe), swe) from swe - - finally: - self.execution_strategy.save(self.bpmn_process_instance) - db.session.commit() - - if save: - self.process_instance_saver() + self.execution_strategy.spiff_run(self.bpmn_process_instance, exit_at) + if self.bpmn_process_instance.is_completed(): + self.process_instance_completer(self.bpmn_process_instance) def process_bpmn_messages(self) -> None: """Process_bpmn_messages.""" @@ -407,11 +414,11 @@ class WorkflowExecutionService: class ProfiledWorkflowExecutionService(WorkflowExecutionService): """A profiled version of the workflow execution service.""" - def run(self, exit_at: None = None, save: bool = False) -> None: + def run_and_save(self, exit_at: None = None, save: bool = False) -> None: """__do_engine_steps.""" import cProfile from pstats import SortKey with cProfile.Profile() as pr: - super().run(exit_at=exit_at, save=save) + super().run_and_save(exit_at=exit_at, save=save) pr.print_stats(sort=SortKey.CUMULATIVE) diff --git a/spiffworkflow-frontend/package-lock.json b/spiffworkflow-frontend/package-lock.json index a578aa3a..0578dd91 100644 --- a/spiffworkflow-frontend/package-lock.json +++ b/spiffworkflow-frontend/package-lock.json @@ -17,6 +17,7 @@ "@casl/ability": "^6.3.2", "@casl/react": "^3.1.0", "@ginkgo-bioworks/react-json-schema-form-builder": "^2.9.0", + "@microsoft/fetch-event-source": "^2.0.1", "@monaco-editor/react": "^4.4.5", "@mui/material": "^5.10.14", "@react-icons/all-files": "^4.1.0", @@ -4473,6 +4474,11 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" + }, "node_modules/@monaco-editor/loader": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.2.tgz", @@ -8066,7 +8072,7 @@ }, "node_modules/bpmn-js-spiffworkflow": { "version": "0.0.8", - "resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#24a71ec5e2cbbefce58be4b4610151db4a55a8e1", + "resolved": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#69135655f8a5282bcdaef82705c3d522ef5b4464", "license": "MIT", "dependencies": { "inherits": "^2.0.4", @@ -35584,6 +35590,11 @@ "@lezer/common": "^1.0.0" } }, + "@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" + }, "@monaco-editor/loader": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.3.2.tgz", @@ -38227,7 +38238,7 @@ } }, "bpmn-js-spiffworkflow": { - "version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#24a71ec5e2cbbefce58be4b4610151db4a55a8e1", + "version": "git+ssh://git@github.com/sartography/bpmn-js-spiffworkflow.git#69135655f8a5282bcdaef82705c3d522ef5b4464", "from": "bpmn-js-spiffworkflow@sartography/bpmn-js-spiffworkflow#main", "requires": { "inherits": "^2.0.4", diff --git a/spiffworkflow-frontend/package.json b/spiffworkflow-frontend/package.json index 5294eff1..ddd351e4 100644 --- a/spiffworkflow-frontend/package.json +++ b/spiffworkflow-frontend/package.json @@ -12,6 +12,7 @@ "@casl/ability": "^6.3.2", "@casl/react": "^3.1.0", "@ginkgo-bioworks/react-json-schema-form-builder": "^2.9.0", + "@microsoft/fetch-event-source": "^2.0.1", "@monaco-editor/react": "^4.4.5", "@mui/material": "^5.10.14", "@react-icons/all-files": "^4.1.0", diff --git a/spiffworkflow-frontend/src/App.tsx b/spiffworkflow-frontend/src/App.tsx index ddaa68ae..8031802c 100644 --- a/spiffworkflow-frontend/src/App.tsx +++ b/spiffworkflow-frontend/src/App.tsx @@ -8,6 +8,7 @@ import NavigationBar from './components/NavigationBar'; import HomePageRoutes from './routes/HomePageRoutes'; import ErrorBoundary from './components/ErrorBoundary'; import AdminRoutes from './routes/AdminRoutes'; +import ProcessRoutes from './routes/ProcessRoutes'; import { AbilityContext } from './contexts/Can'; import UserService from './services/UserService'; @@ -35,6 +36,7 @@ export default function App() { } /> } /> + } /> } /> diff --git a/spiffworkflow-frontend/src/routes/HomePageRoutes.tsx b/spiffworkflow-frontend/src/routes/HomePageRoutes.tsx index e425c125..061aa248 100644 --- a/spiffworkflow-frontend/src/routes/HomePageRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/HomePageRoutes.tsx @@ -7,6 +7,7 @@ import MyTasks from './MyTasks'; import CompletedInstances from './CompletedInstances'; import CreateNewInstance from './CreateNewInstance'; import InProgressInstances from './InProgressInstances'; +import ProcessInterstitial from './ProcessInterstitial'; export default function HomePageRoutes() { const location = useLocation(); @@ -55,6 +56,7 @@ export default function HomePageRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/spiffworkflow-frontend/src/routes/TaskShow.tsx b/spiffworkflow-frontend/src/routes/TaskShow.tsx index 863ee5f3..5561efad 100644 --- a/spiffworkflow-frontend/src/routes/TaskShow.tsx +++ b/spiffworkflow-frontend/src/routes/TaskShow.tsx @@ -17,6 +17,7 @@ import { import MDEditor from '@uiw/react-md-editor'; // eslint-disable-next-line import/no-named-as-default import Form from '../themes/carbon'; +import Loading from '../themes/carbon'; import HttpService from '../services/HttpService'; import useAPIError from '../hooks/UseApiError'; import { modifyProcessIdentifierForPathParam } from '../helpers'; @@ -90,6 +91,8 @@ function TypeAheadWidget({ ); } + + class UnexpectedHumanTaskType extends Error { constructor(message: string) { super(message); @@ -108,6 +111,7 @@ export default function TaskShow() { const params = useParams(); const navigate = useNavigate(); const [disabled, setDisabled] = useState(false); + const [refreshSeconds, setRefreshSeconds] = useState(0); // save current form data so that we can avoid validations in certain situations const [currentFormObject, setCurrentFormObject] = useState({}); @@ -117,6 +121,7 @@ export default function TaskShow() { // eslint-disable-next-line sonarjs/no-duplicate-string const supportedHumanTaskTypes = ['User Task', 'Manual Task']; + useEffect(() => { const processResult = (result: ProcessInstanceTask) => { setTask(result); @@ -157,7 +162,15 @@ export default function TaskShow() { if (result.ok) { navigate(`/tasks`); } else if (result.process_instance_id) { - navigate(`/tasks/${result.process_instance_id}/${result.id}`); + if (result.type in supportedHumanTaskTypes) { + navigate(`/tasks/${result.process_instance_id}/${result.id}`); + } else { + navigate( + `/process/${modifyProcessIdentifierForPathParam( + result.process_model_identifier + )}/${result.process_instance_id}/interstitial` + ); + } } else { addError(result); } @@ -344,8 +357,10 @@ export default function TaskShow() { ); } else { - throw new UnexpectedHumanTaskType( - `Invalid task type given: ${task.type}. Only supported types: ${supportedHumanTaskTypes}` + return ( +

+ Page will refresh in {refreshSeconds} seconds. +

); } reactFragmentToHideSubmitButton = ( diff --git a/spiffworkflow-frontend/src/services/HttpService.ts b/spiffworkflow-frontend/src/services/HttpService.ts index ed2e5149..83d71af8 100644 --- a/spiffworkflow-frontend/src/services/HttpService.ts +++ b/spiffworkflow-frontend/src/services/HttpService.ts @@ -8,7 +8,7 @@ const HttpMethods = { DELETE: 'DELETE', }; -const getBasicHeaders = (): object => { +export const getBasicHeaders = (): Record => { if (UserService.isLoggedIn()) { return { Authorization: `Bearer ${UserService.getAccessToken()}`,