From c505e810065d9fba57f0236b03b574df1f4d4419 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 30 Jul 2026 17:42:10 +0200 Subject: [PATCH] feat: add BPMN inspection and workflow progress visuals --- docs/BPMN_INTEROPERABILITY.md | 37 +++ docs/VISUAL_MODEL.md | 24 ++ pyproject.toml | 2 +- src/govoplan_workflow/backend/bpmn.py | 309 ++++++++++++++++++ src/govoplan_workflow/backend/router.py | 91 ++++++ src/govoplan_workflow/backend/schemas.py | 48 +++ tests/test_bpmn.py | 85 +++++ .../features/workflow/WorkflowRunsDialog.tsx | 41 ++- webui/src/styles/workflow.css | 111 ++++++- 9 files changed, 736 insertions(+), 12 deletions(-) create mode 100644 docs/BPMN_INTEROPERABILITY.md create mode 100644 docs/VISUAL_MODEL.md create mode 100644 src/govoplan_workflow/backend/bpmn.py create mode 100644 tests/test_bpmn.py diff --git a/docs/BPMN_INTEROPERABILITY.md b/docs/BPMN_INTEROPERABILITY.md new file mode 100644 index 0000000..78f3d5f --- /dev/null +++ b/docs/BPMN_INTEROPERABILITY.md @@ -0,0 +1,37 @@ +# BPMN Interoperability + +GovOPlaN distinguishes BPMN notation and XML interchange from executable +workflow semantics. + +## Current Contract + +- `GET /api/v1/workflow/bpmn/profile` publishes the exact native support + profile. +- `POST /api/v1/workflow/bpmn/inspect` safely parses bounded BPMN 2.0 XML, + inventories every BPMN model element, detects duplicate IDs and selected + dangling references, and classifies elements as interchange-only, natively + mappable, or natively executable. +- XML entities, DTD-based expansion, oversized documents, and malformed roots + are rejected. +- The native GovOPlaN graph remains the authoritative executable definition. + +Inspection is not XML Schema validation and does not claim that every BPMN +semantic construct can be executed. A future `bpmn-js`/`bpmn-moddle` adapter +can provide complete visual notation and XML round-tripping without forcing +unsupported elements into the native runner. + +## Execution Boundary + +Adding a BPMN shape is not equivalent to implementing its token semantics, +event subscriptions, compensation, transactions, choreography, or conformance +behavior. Each executable mapping therefore needs: + +1. an explicit native semantic mapping; +2. validation rules and lifecycle behavior; +3. resumability and idempotency tests; +4. migration and round-trip fixtures; +5. a declared fallback when the installed runtime cannot execute it. + +Unsupported constructs remain visible and preserved by the future interchange +adapter, but activation must remain blocked until an execution adapter declares +support. diff --git a/docs/VISUAL_MODEL.md b/docs/VISUAL_MODEL.md new file mode 100644 index 0000000..76db566 --- /dev/null +++ b/docs/VISUAL_MODEL.md @@ -0,0 +1,24 @@ +# Workflow Visual Model + +The Campaign review flow is the reference for runtime workflow progress: + +- a compact stage rail communicates order, current state, completion, warning, + failure, partial progress, and locks; +- the active handoff owns the detailed controls; +- evidence remains visible without turning every stage into a permanent card; +- unavailable stages stay visibly unavailable while non-blocking optional + stages do not interrupt the connector state. + +Workflow now applies that language to instance progress without importing +Campaign code. Once the state vocabulary has stabilized, the rail should move +to Core as a generic process-stage component and Campaign should consume it. + +Navigation has three distinct layers: + +1. the platform siderail selects a module or focused View; +2. the module workspace selects an object or definition; +3. the workflow stage rail describes progress inside that object. + +A focused View or active Workflow may suppress unrelated platform and module +navigation, but must always provide a visible escape back to the normal View. +Modules should not add another persistent navigation tier for workflow stages. diff --git a/pyproject.toml b/pyproject.toml index 6b7cca0..9570a62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.12" license = "AGPL-3.0-or-later" authors = [{ name = "GovOPlaN" }] -dependencies = ["govoplan-core>=0.1.14"] +dependencies = ["defusedxml>=0.7,<1", "govoplan-core>=0.1.14"] [tool.setuptools.packages.find] where = ["src"] diff --git a/src/govoplan_workflow/backend/bpmn.py b/src/govoplan_workflow/backend/bpmn.py new file mode 100644 index 0000000..1361d31 --- /dev/null +++ b/src/govoplan_workflow/backend/bpmn.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass +from typing import Literal +from xml.etree.ElementTree import ParseError + +from defusedxml.ElementTree import fromstring +from defusedxml.common import DefusedXmlException + + +BPMN_MODEL_NAMESPACE = "http://www.omg.org/spec/BPMN/20100524/MODEL" +BPMN_DI_NAMESPACE = "http://www.omg.org/spec/BPMN/20100524/DI" +OMG_DI_NAMESPACE = "http://www.omg.org/spec/DD/20100524/DI" +OMG_DC_NAMESPACE = "http://www.omg.org/spec/DD/20100524/DC" +MAX_BPMN_XML_BYTES = 1_048_576 +MAX_BPMN_ELEMENTS = 20_000 + +SupportLevel = Literal[ + "interchange_only", + "native_mapping", + "native_execution", +] + + +NATIVE_EXECUTION_ELEMENTS = frozenset( + { + "startEvent", + "endEvent", + "manualTask", + "userTask", + "serviceTask", + "businessRuleTask", + "receiveTask", + "sendTask", + "exclusiveGateway", + "parallelGateway", + "sequenceFlow", + "intermediateCatchEvent", + "intermediateThrowEvent", + } +) + +NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset( + { + "task", + "scriptTask", + "callActivity", + "subProcess", + "transaction", + "adHocSubProcess", + "inclusiveGateway", + "eventBasedGateway", + "complexGateway", + "boundaryEvent", + "eventSubProcess", + "dataObject", + "dataObjectReference", + "dataStoreReference", + "messageFlow", + "participant", + "lane", + "laneSet", + "textAnnotation", + "association", + "group", + } +) + + +@dataclass(frozen=True, slots=True) +class BpmnDiagnostic: + severity: Literal["error", "warning", "info"] + code: str + message: str + element_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class BpmnElementInventoryItem: + element_type: str + element_id: str | None + name: str | None + parent_type: str | None + parent_id: str | None + support_level: SupportLevel + + +@dataclass(frozen=True, slots=True) +class BpmnInspection: + definitions_id: str | None + target_namespace: str | None + process_count: int + executable_process_count: int + collaboration_count: int + choreography_count: int + element_counts: dict[str, int] + support_counts: dict[str, int] + elements: tuple[BpmnElementInventoryItem, ...] + diagnostics: tuple[BpmnDiagnostic, ...] + + @property + def valid_xml(self) -> bool: + return not any(item.severity == "error" for item in self.diagnostics) + + +class BpmnInspectionError(ValueError): + pass + + +def bpmn_support_level(element_type: str) -> SupportLevel: + if element_type in NATIVE_EXECUTION_ELEMENTS: + return "native_execution" + if element_type in NATIVE_MAPPING_ELEMENTS: + return "native_mapping" + return "interchange_only" + + +def inspect_bpmn_xml(xml: str) -> BpmnInspection: + encoded = xml.encode("utf-8") + if not encoded: + raise BpmnInspectionError("BPMN XML is empty") + if len(encoded) > MAX_BPMN_XML_BYTES: + raise BpmnInspectionError( + f"BPMN XML exceeds the {MAX_BPMN_XML_BYTES}-byte inspection limit" + ) + try: + root = fromstring(encoded) + except (DefusedXmlException, ParseError, ValueError) as exc: + raise BpmnInspectionError(f"BPMN XML is not safe and well formed: {exc}") from exc + + namespace, local_name = _qualified_name(root.tag) + if namespace != BPMN_MODEL_NAMESPACE or local_name != "definitions": + raise BpmnInspectionError( + "BPMN document root must be bpmn:definitions in the BPMN 2.0 model namespace" + ) + + diagnostics: list[BpmnDiagnostic] = [] + elements: list[BpmnElementInventoryItem] = [] + ids: dict[str, str] = {} + references: list[tuple[str, str | None, str]] = [] + process_count = 0 + executable_process_count = 0 + collaboration_count = 0 + choreography_count = 0 + + stack = [(root, None, None)] + visited = 0 + while stack: + element, parent_type, parent_id = stack.pop() + visited += 1 + if visited > MAX_BPMN_ELEMENTS: + raise BpmnInspectionError( + f"BPMN document exceeds the {MAX_BPMN_ELEMENTS}-element inspection limit" + ) + element_namespace, element_type = _qualified_name(element.tag) + element_id = _bounded_attribute(element.attrib.get("id"), 255) + if element_namespace == BPMN_MODEL_NAMESPACE: + name = _bounded_attribute(element.attrib.get("name"), 300) + support = bpmn_support_level(element_type) + elements.append( + BpmnElementInventoryItem( + element_type=element_type, + element_id=element_id, + name=name, + parent_type=parent_type, + parent_id=parent_id, + support_level=support, + ) + ) + if element_id: + previous = ids.get(element_id) + if previous is not None: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="duplicate_bpmn_id", + message=( + f"BPMN id {element_id!r} is used by both " + f"{previous} and {element_type}" + ), + element_id=element_id, + ) + ) + else: + ids[element_id] = element_type + if element_type == "process": + process_count += 1 + if element.attrib.get("isExecutable", "").strip().lower() == "true": + executable_process_count += 1 + elif element_type == "collaboration": + collaboration_count += 1 + elif element_type in {"choreography", "globalChoreographyTask"}: + choreography_count += 1 + _collect_references(element_type, element_id, element.attrib, references) + next_parent_type = element_type + next_parent_id = element_id + else: + next_parent_type = parent_type + next_parent_id = parent_id + for child in reversed(list(element)): + stack.append((child, next_parent_type, next_parent_id)) + + if process_count == 0 and collaboration_count == 0 and choreography_count == 0: + diagnostics.append( + BpmnDiagnostic( + severity="warning", + code="no_bpmn_process_or_collaboration", + message="BPMN definitions contain no process, collaboration, or choreography", + ) + ) + for reference, element_id, field in references: + if reference not in ids: + diagnostics.append( + BpmnDiagnostic( + severity="error", + code="dangling_bpmn_reference", + message=( + f"{field} references unknown BPMN id {reference!r}" + ), + element_id=element_id, + ) + ) + unsupported = [ + item + for item in elements + if item.support_level == "interchange_only" + and item.element_type not in {"definitions", "process"} + ] + if unsupported: + diagnostics.append( + BpmnDiagnostic( + severity="info", + code="interchange_only_elements", + message=( + f"{len(unsupported)} BPMN element(s) can be inventoried and " + "round-tripped by a BPMN modeler but are not executable by " + "the native GovOPlaN workflow runtime" + ), + ) + ) + element_counts = dict( + sorted(Counter(item.element_type for item in elements).items()) + ) + support_counts = dict( + sorted(Counter(item.support_level for item in elements).items()) + ) + return BpmnInspection( + definitions_id=_bounded_attribute(root.attrib.get("id"), 255), + target_namespace=_bounded_attribute( + root.attrib.get("targetNamespace"), + 1000, + ), + process_count=process_count, + executable_process_count=executable_process_count, + collaboration_count=collaboration_count, + choreography_count=choreography_count, + element_counts=element_counts, + support_counts=support_counts, + elements=tuple(elements), + diagnostics=tuple(diagnostics), + ) + + +def _collect_references( + element_type: str, + element_id: str | None, + attributes: dict[str, str], + references: list[tuple[str, str | None, str]], +) -> None: + fields: tuple[str, ...] + if element_type == "sequenceFlow": + fields = ("sourceRef", "targetRef") + elif element_type == "messageFlow": + fields = ("sourceRef", "targetRef", "messageRef") + elif element_type == "participant": + fields = ("processRef",) + elif element_type == "lane": + fields = ("partitionElementRef",) + else: + fields = () + for field in fields: + value = attributes.get(field) + if value: + references.append((value, element_id, field)) + + +def _qualified_name(tag: str) -> tuple[str | None, str]: + if tag.startswith("{") and "}" in tag: + namespace, local_name = tag[1:].split("}", 1) + return namespace, local_name + return None, tag + + +def _bounded_attribute(value: str | None, limit: int) -> str | None: + if value is None: + return None + text = value.strip() + return text[:limit] if text else None + + +__all__ = [ + "BPMN_MODEL_NAMESPACE", + "BpmnInspectionError", + "NATIVE_EXECUTION_ELEMENTS", + "NATIVE_MAPPING_ELEMENTS", + "bpmn_support_level", + "inspect_bpmn_xml", +] diff --git a/src/govoplan_workflow/backend/router.py b/src/govoplan_workflow/backend/router.py index f5a4ee8..44f941c 100644 --- a/src/govoplan_workflow/backend/router.py +++ b/src/govoplan_workflow/backend/router.py @@ -29,7 +29,19 @@ from govoplan_workflow.backend.manifest import ( INSTANCE_TRANSITION_SCOPE, ) from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY +from govoplan_workflow.backend.bpmn import ( + BPMN_MODEL_NAMESPACE, + BpmnInspectionError, + NATIVE_EXECUTION_ELEMENTS, + NATIVE_MAPPING_ELEMENTS, + inspect_bpmn_xml, +) from govoplan_workflow.backend.schemas import ( + BpmnDiagnosticResponse, + BpmnElementSupportResponse, + BpmnInspectionRequest, + BpmnInspectionResponse, + BpmnSupportProfileResponse, WorkflowConfigFieldResponse, WorkflowDefinitionActivateRequest, WorkflowDefinitionCreateRequest, @@ -204,6 +216,85 @@ def _require_instance_view(instance, principal: ApiPrincipal) -> None: ) +@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse) +def api_bpmn_support_profile( + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnSupportProfileResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + return BpmnSupportProfileResponse( + specification="BPMN 2.0.2", + model_namespace=BPMN_MODEL_NAMESPACE, + interchange=( + "Secure XML inventory and diagnostics are available. Visual " + "round-trip modeling requires the planned bpmn-js adapter." + ), + native_runtime=( + "Only the explicitly listed executable subset maps to current " + "native runtime semantics; all other elements are interchange-only." + ), + native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS), + native_mapping_elements=sorted( + NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS + ), + ) + + +@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse) +def api_inspect_bpmn( + payload: BpmnInspectionRequest, + principal: ApiPrincipal = Depends(get_api_principal), +) -> BpmnInspectionResponse: + _require_any_scope( + principal, + DEFINITION_READ_SCOPE, + DEFINITION_WRITE_SCOPE, + ADMIN_SCOPE, + ) + try: + result = inspect_bpmn_xml(payload.xml) + except BpmnInspectionError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + return BpmnInspectionResponse( + valid_xml=result.valid_xml, + definitions_id=result.definitions_id, + target_namespace=result.target_namespace, + process_count=result.process_count, + executable_process_count=result.executable_process_count, + collaboration_count=result.collaboration_count, + choreography_count=result.choreography_count, + element_counts=result.element_counts, + support_counts=result.support_counts, + elements=[ + BpmnElementSupportResponse( + element_type=item.element_type, + element_id=item.element_id, + name=item.name, + parent_type=item.parent_type, + parent_id=item.parent_id, + support_level=item.support_level, + ) + for item in result.elements + ], + diagnostics=[ + BpmnDiagnosticResponse( + severity=item.severity, + code=item.code, + message=item.message, + element_id=item.element_id, + ) + for item in result.diagnostics + ], + ) + + @router.get("/node-types", response_model=WorkflowNodeLibraryResponse) def api_node_types( principal: ApiPrincipal = Depends(get_api_principal), diff --git a/src/govoplan_workflow/backend/schemas.py b/src/govoplan_workflow/backend/schemas.py index 6b5b20d..c693d25 100644 --- a/src/govoplan_workflow/backend/schemas.py +++ b/src/govoplan_workflow/backend/schemas.py @@ -10,6 +10,11 @@ from pydantic import BaseModel, Field, field_validator WorkflowDefinitionStatus = Literal["draft", "active", "archived"] DefinitionScopeType = Literal["system", "tenant", "group", "user"] DefinitionKind = Literal["flow", "template"] +BpmnSupportLevel = Literal[ + "interchange_only", + "native_mapping", + "native_execution", +] class WorkflowPosition(BaseModel): @@ -100,6 +105,49 @@ class WorkflowNodeLibraryResponse(BaseModel): nodes: list[WorkflowNodeTypeResponse] +class BpmnInspectionRequest(BaseModel): + xml: str = Field(min_length=1, max_length=1_048_576) + + +class BpmnElementSupportResponse(BaseModel): + element_type: str + element_id: str | None = None + name: str | None = None + parent_type: str | None = None + parent_id: str | None = None + support_level: BpmnSupportLevel + + +class BpmnDiagnosticResponse(BaseModel): + severity: Literal["error", "warning", "info"] + code: str + message: str + element_id: str | None = None + + +class BpmnInspectionResponse(BaseModel): + valid_xml: bool + definitions_id: str | None = None + target_namespace: str | None = None + process_count: int + executable_process_count: int + collaboration_count: int + choreography_count: int + element_counts: dict[str, int] + support_counts: dict[str, int] + elements: list[BpmnElementSupportResponse] + diagnostics: list[BpmnDiagnosticResponse] + + +class BpmnSupportProfileResponse(BaseModel): + specification: str + model_namespace: str + interchange: str + native_runtime: str + native_execution_elements: list[str] + native_mapping_elements: list[str] + + class WorkflowDefinitionRevisionResponse(BaseModel): id: str revision: int diff --git a/tests/test_bpmn.py b/tests/test_bpmn.py new file mode 100644 index 0000000..7221bab --- /dev/null +++ b/tests/test_bpmn.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import unittest + +from govoplan_workflow.backend.bpmn import ( + BpmnInspectionError, + inspect_bpmn_xml, +) + + +BPMN = """ + + + + + + + + + + + + + + +""" + + +class BpmnInspectionTests(unittest.TestCase): + def test_inventory_classifies_native_and_interchange_elements(self) -> None: + result = inspect_bpmn_xml(BPMN) + + self.assertTrue(result.valid_xml) + self.assertEqual(1, result.process_count) + self.assertEqual(1, result.executable_process_count) + self.assertEqual(1, result.collaboration_count) + self.assertEqual(3, result.element_counts["sequenceFlow"]) + review = next( + item for item in result.elements if item.element_id == "Review_1" + ) + collaboration = next( + item + for item in result.elements + if item.element_id == "Collaboration_1" + ) + self.assertEqual("native_execution", review.support_level) + self.assertEqual("interchange_only", collaboration.support_level) + + def test_dangling_references_are_reported(self) -> None: + result = inspect_bpmn_xml( + BPMN.replace('targetRef="End_1"', 'targetRef="Missing_1"') + ) + + self.assertFalse(result.valid_xml) + self.assertTrue( + any( + item.code == "dangling_bpmn_reference" + for item in result.diagnostics + ) + ) + + def test_entities_are_rejected(self) -> None: + unsafe = """]> +&xxe;""" + + with self.assertRaisesRegex( + BpmnInspectionError, + "not safe and well formed", + ): + inspect_bpmn_xml(unsafe) + + def test_non_bpmn_root_is_rejected(self) -> None: + with self.assertRaisesRegex( + BpmnInspectionError, + "bpmn:definitions", + ): + inspect_bpmn_xml("") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/src/features/workflow/WorkflowRunsDialog.tsx b/webui/src/features/workflow/WorkflowRunsDialog.tsx index 29c28e3..b90f18b 100644 --- a/webui/src/features/workflow/WorkflowRunsDialog.tsx +++ b/webui/src/features/workflow/WorkflowRunsDialog.tsx @@ -5,6 +5,10 @@ import { useState } from "react"; import { + AlertTriangle, + Check, + Circle, + Clock3, ExternalLink, Play, RefreshCw, @@ -417,13 +421,36 @@ export default function WorkflowRunsDialog({

Progress

{selected.steps.map((step) => ( - - {step.node_id} - - {step.node_type} · attempt {step.attempt} - - - +
+ + {step.status === "completed" ? ( + + + {step.node_id} + + {step.node_type} · attempt {step.attempt} + + + +
))}
diff --git a/webui/src/styles/workflow.css b/webui/src/styles/workflow.css index 07a52ea..5ae32e6 100644 --- a/webui/src/styles/workflow.css +++ b/webui/src/styles/workflow.css @@ -328,14 +328,20 @@ .workflow-palette-items { display: grid; + grid-auto-rows: max-content; + align-content: start; height: calc(100% - 44px); gap: 4px; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; padding: 8px; + scrollbar-gutter: stable; } .workflow-palette-group { display: grid; + grid-auto-rows: max-content; + align-content: start; gap: 2px; } @@ -548,11 +554,15 @@ .workflow-inspector-fields { display: grid; + grid-auto-rows: max-content; + align-content: start; gap: 12px; - min-height: 0; + height: calc(100% - 44px); flex: 1 1 auto; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; padding: 12px; + scrollbar-gutter: stable; } .workflow-inspector-fields input, @@ -879,7 +889,6 @@ display: grid; } -.workflow-run-history > div > span, .workflow-run-events > div > span { display: grid; grid-template-columns: minmax(0, 1fr) minmax(160px, auto) auto; @@ -890,6 +899,100 @@ padding: 6px 2px; } +.workflow-run-history > div { + grid-template-columns: repeat( + auto-fit, + minmax(min(150px, 100%), 1fr) + ); + overflow-x: auto; + padding: 2px 0 4px; +} + +.workflow-run-stage { + --workflow-stage-color: var(--line-dark); + position: relative; + display: grid; + min-width: 140px; + grid-template-columns: 32px minmax(0, 1fr); + gap: 7px; + padding: 4px 12px 4px 0; +} + +.workflow-run-stage[data-state="completed"] { + --workflow-stage-color: var(--green); +} + +.workflow-run-stage[data-state="running"], +.workflow-run-stage[data-state="waiting"] { + --workflow-stage-color: var(--blue); +} + +.workflow-run-stage[data-state="failed"], +.workflow-run-stage[data-state="cancelled"] { + --workflow-stage-color: var(--red); +} + +.workflow-run-stage[data-state="superseded"] { + --workflow-stage-color: var(--muted); +} + +.workflow-run-stage:not(:last-child)::after { + position: absolute; + z-index: 0; + top: 18px; + left: 27px; + width: calc(100% - 17px); + height: 2px; + background: linear-gradient( + 90deg, + var(--workflow-stage-color), + var(--line-dark) + ); + content: ""; +} + +.workflow-run-stage-marker { + z-index: 1; + display: grid; + width: 30px; + height: 30px; + place-items: center; + border: 1px solid var(--workflow-stage-color); + border-radius: 50%; + background: var(--panel); + color: var(--workflow-stage-color); +} + +.workflow-run-stage[data-current="true"] .workflow-run-stage-marker { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--workflow-stage-color) 18%, transparent); +} + +.workflow-run-stage-copy { + z-index: 1; + display: grid; + min-width: 0; + align-content: start; + gap: 3px; + padding-top: 2px; +} + +.workflow-run-stage-copy strong, +.workflow-run-stage-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-run-stage-copy strong { + color: var(--text-strong); + font-size: 11px; +} + +.workflow-run-stage-copy .status-badge { + width: fit-content; + margin-top: 2px; +} + .workflow-run-history small, .workflow-run-events small { color: var(--muted);