Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e5415dfe7 | ||
|
|
d5214c641c | ||
|
|
0fd744a2db | ||
|
|
f49ad8eff4 | ||
|
|
244c0ee4c1 | ||
|
|
a071692e75 | ||
|
|
55bbd15ecc | ||
|
|
8305bd5c5b |
+3
-3
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-workflow"
|
name = "govoplan-workflow"
|
||||||
version = "0.1.17"
|
version = "0.1.20"
|
||||||
description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine."
|
description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.17",
|
"govoplan-core>=0.1.28",
|
||||||
"govoplan-workflow-engine>=0.1.17",
|
"govoplan-workflow-engine>=0.1.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Optional GovOPlaN Workflow editor and compatibility package."""
|
"""Optional GovOPlaN Workflow editor and compatibility package."""
|
||||||
|
|
||||||
__version__ = "0.1.17"
|
__version__ = "0.1.18"
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
FrontendRoute,
|
FrontendRoute,
|
||||||
|
ModuleContext,
|
||||||
ModuleInterfaceProvider,
|
ModuleInterfaceProvider,
|
||||||
ModuleInterfaceRequirement,
|
ModuleInterfaceRequirement,
|
||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
|
ProductAreaContribution,
|
||||||
ViewSurface,
|
ViewSurface,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
semantic_documentation_subject_capability,
|
||||||
|
)
|
||||||
|
from govoplan_workflow.backend.semantic_subjects import (
|
||||||
|
WorkflowSemanticDocumentationSubjectProvider,
|
||||||
|
)
|
||||||
from govoplan_workflow_engine.backend.manifest import (
|
from govoplan_workflow_engine.backend.manifest import (
|
||||||
ADMIN_SCOPE,
|
ADMIN_SCOPE,
|
||||||
DEFINITION_READ_SCOPE,
|
DEFINITION_READ_SCOPE,
|
||||||
@@ -24,7 +35,14 @@ from govoplan_workflow_engine.backend.manifest import (
|
|||||||
|
|
||||||
MODULE_ID = "workflow"
|
MODULE_ID = "workflow"
|
||||||
MODULE_NAME = "Workflow"
|
MODULE_NAME = "Workflow"
|
||||||
MODULE_VERSION = "0.1.17"
|
MODULE_VERSION = "0.1.20"
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_subjects(
|
||||||
|
context: ModuleContext,
|
||||||
|
) -> WorkflowSemanticDocumentationSubjectProvider:
|
||||||
|
return WorkflowSemanticDocumentationSubjectProvider(context.registry)
|
||||||
|
|
||||||
|
|
||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
@@ -32,9 +50,28 @@ manifest = ModuleManifest(
|
|||||||
name=MODULE_NAME,
|
name=MODULE_NAME,
|
||||||
version=MODULE_VERSION,
|
version=MODULE_VERSION,
|
||||||
dependencies=("workflow_engine",),
|
dependencies=("workflow_engine",),
|
||||||
|
optional_dependencies=("docs",),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION),
|
ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=SEMANTIC_SUBJECT_CAPABILITY,
|
||||||
|
version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
capability_factories={
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects,
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
SEMANTIC_SUBJECT_CAPABILITY: CapabilityDocumentation(
|
||||||
|
label="Workflow semantic-documentation subjects",
|
||||||
|
summary=(
|
||||||
|
"Lists currently authorized workflow definitions and stable "
|
||||||
|
"step lineages with documentation-safe review fingerprints."
|
||||||
|
),
|
||||||
|
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
),
|
||||||
|
},
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
name="workflow.definition_graph",
|
name="workflow.definition_graph",
|
||||||
@@ -81,6 +118,17 @@ manifest = ModuleManifest(
|
|||||||
order=74,
|
order=74,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
product_areas=(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id="work",
|
||||||
|
module_id=MODULE_ID,
|
||||||
|
label="i18n:govoplan-core.product_area.work",
|
||||||
|
icon="list-checks",
|
||||||
|
description="i18n:govoplan-core.product_area.work_description",
|
||||||
|
surface_ids=("workflow.nav.workflow", "workflow.route.workflow"),
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
view_surfaces=(
|
view_surfaces=(
|
||||||
ViewSurface(
|
ViewSurface(
|
||||||
id="workflow.widget.open-work",
|
id="workflow.widget.open-work",
|
||||||
@@ -92,6 +140,99 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="workflow.external-handoff-inspection",
|
||||||
|
title="Inspect and return to external work hand-offs",
|
||||||
|
summary=(
|
||||||
|
"Follow the exact linked module work while Workflow retains "
|
||||||
|
"its durable position and immutable reference."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"The runs dialog shows the current external hand-off state, exact "
|
||||||
|
"revision reference, safe linked-work action, timeout, and the "
|
||||||
|
"immutable Workflow evidence trail. Leaving the dialog or linked "
|
||||||
|
"Campaign page does not complete the step. Declared Campaign events "
|
||||||
|
"drive acceptance, reassignment, completion, rejection, and "
|
||||||
|
"cancellation without browser polling. A configured focused View "
|
||||||
|
"narrows the available surfaces while the step is active. If Views "
|
||||||
|
"is absent, the linked Campaign action remains available and the "
|
||||||
|
"dialog explains the reduced presentation. Missing optional Tasks or "
|
||||||
|
"Notifications is also shown without changing the hand-off outcome."
|
||||||
|
),
|
||||||
|
layer="available",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("operator", "workflow_designer", "campaign_manager"),
|
||||||
|
related_modules=(
|
||||||
|
"workflow_engine",
|
||||||
|
"campaigns",
|
||||||
|
"views",
|
||||||
|
"tasks",
|
||||||
|
"notifications",
|
||||||
|
),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
any_scopes=(INSTANCE_READ_SCOPE, ADMIN_SCOPE),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"help_contexts": [
|
||||||
|
"workflow.external-handoff",
|
||||||
|
"workflow.instances",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
order=74,
|
||||||
|
),
|
||||||
|
DocumentationTopic(
|
||||||
|
id="workflow.semantic-documentation",
|
||||||
|
title="Document configured workflow and step meaning",
|
||||||
|
summary=(
|
||||||
|
"Attach tenant-owned semantic guidance to an authorized workflow "
|
||||||
|
"definition or stable step without changing execution."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"When Docs is installed, Workflow supplies documentation-safe "
|
||||||
|
"subjects for each definition the current actor may view and for "
|
||||||
|
"each step in its current immutable revision. Step identity survives "
|
||||||
|
"label and canvas-position changes. Removing and later recreating a "
|
||||||
|
"step with the same graph ID creates a new lineage, leaving prior "
|
||||||
|
"documentation explicitly orphaned. Relevant changes to step type, "
|
||||||
|
"label, hierarchy, safe configuration, or connected flow semantics "
|
||||||
|
"change the review fingerprint; layout-only movement does not. "
|
||||||
|
"Semantic prose can explain purpose, non-purpose, entry and exit "
|
||||||
|
"conditions, handoffs, exceptions, ownership, stewardship, and "
|
||||||
|
"examples, but it cannot alter validation, activation, policy, or "
|
||||||
|
"Workflow Engine execution. Workflow rechecks tenant scope and the "
|
||||||
|
"current per-definition governance decision for discovery and direct "
|
||||||
|
"resolution. If Docs is absent, editing and static help continue."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=(
|
||||||
|
"user",
|
||||||
|
"workflow_designer",
|
||||||
|
"process_owner",
|
||||||
|
"module_admin",
|
||||||
|
),
|
||||||
|
related_modules=("docs", "workflow_engine", "policy"),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Semantic documentation authoring",
|
||||||
|
href="/docs/semantic",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Workflow editor and engine boundary",
|
||||||
|
href="govoplan-workflow/docs/ENGINE_EDITOR_SPLIT.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["workflow.semantic-documentation"],
|
||||||
|
},
|
||||||
|
order=75,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="workflow.editor",
|
id="workflow.editor",
|
||||||
title="Workflow editor and inspection workspace",
|
title="Workflow editor and inspection workspace",
|
||||||
@@ -126,8 +267,16 @@ manifest = ModuleManifest(
|
|||||||
maturity="vertical_slice",
|
maturity="vertical_slice",
|
||||||
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
|
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
|
||||||
test_ref="tests/test_manifest.py",
|
test_ref="tests/test_manifest.py",
|
||||||
known_limits=("The editor intentionally cannot execute definitions without Workflow Engine and target-tested adapters.",),
|
known_limits=(
|
||||||
owned_concepts=("workflow editing surface", "workflow revision inspection", "workflow activation controls"),
|
"The editor intentionally cannot execute definitions without Workflow Engine and target-tested adapters.",
|
||||||
|
"Semantic documentation requires Docs for tenant-authored content; static Workflow help remains available without it.",
|
||||||
|
),
|
||||||
|
owned_concepts=(
|
||||||
|
"workflow editing surface",
|
||||||
|
"workflow revision inspection",
|
||||||
|
"workflow activation controls",
|
||||||
|
"workflow semantic documentation subjects",
|
||||||
|
),
|
||||||
non_owned_concepts=("workflow definition persistence", "workflow instance execution", "domain action"),
|
non_owned_concepts=("workflow definition persistence", "workflow instance execution", "domain action"),
|
||||||
security_docs=("docs/CONCEPT.md",),
|
security_docs=("docs/CONCEPT.md",),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||||
|
SemanticDocumentationBreadcrumb,
|
||||||
|
SemanticDocumentationSubjectAnchor,
|
||||||
|
SemanticDocumentationSubjectDescriptor,
|
||||||
|
SemanticDocumentationSubjectPage,
|
||||||
|
SemanticDocumentationSubjectQuery,
|
||||||
|
SemanticDocumentationSubjectReference,
|
||||||
|
SemanticDocumentationSubjectResolution,
|
||||||
|
semantic_documentation_fingerprint,
|
||||||
|
)
|
||||||
|
from govoplan_core.security.redaction import redact_secret_values
|
||||||
|
from govoplan_workflow.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
)
|
||||||
|
from govoplan_workflow.backend.governance import definition_decision
|
||||||
|
from govoplan_workflow.backend.service import (
|
||||||
|
get_definition_revision,
|
||||||
|
list_definition_revisions,
|
||||||
|
list_definitions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SUBJECT_KIND = "workflow_definition"
|
||||||
|
ADMIN_SCOPE = "workflow:instance:admin"
|
||||||
|
DEFINITION_READ_SCOPE = "workflow:definition:read"
|
||||||
|
_MAX_SUBJECTS = 20_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _LineageState:
|
||||||
|
node_ids: Mapping[str, str]
|
||||||
|
historical_node_ids: frozenset[str]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowSemanticDocumentationSubjectProvider:
|
||||||
|
provider_id = "workflow.semantic_subjects"
|
||||||
|
module_id = "workflow"
|
||||||
|
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def __init__(self, registry: object | None) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
|
||||||
|
def list_subjects(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: SemanticDocumentationSubjectQuery,
|
||||||
|
) -> SemanticDocumentationSubjectPage:
|
||||||
|
actor = _principal(principal, tenant_id=request.tenant_id)
|
||||||
|
if actor is None:
|
||||||
|
return SemanticDocumentationSubjectPage()
|
||||||
|
if request.subject_kinds and SUBJECT_KIND not in request.subject_kinds:
|
||||||
|
return SemanticDocumentationSubjectPage()
|
||||||
|
db = _session(session)
|
||||||
|
subjects: list[SemanticDocumentationSubjectDescriptor] = []
|
||||||
|
for definition in list_definitions(db, tenant_id=request.tenant_id):
|
||||||
|
if not self._can_view(definition, actor):
|
||||||
|
continue
|
||||||
|
revision = get_definition_revision(db, definition=definition)
|
||||||
|
lineage = _lineage_state(db, definition)
|
||||||
|
subjects.extend(
|
||||||
|
_descriptors(
|
||||||
|
definition,
|
||||||
|
revision,
|
||||||
|
lineage,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(subjects) > _MAX_SUBJECTS:
|
||||||
|
raise ValueError(
|
||||||
|
"Workflow semantic subject limit exceeded; narrow the query."
|
||||||
|
)
|
||||||
|
query = request.query.casefold().strip()
|
||||||
|
if query:
|
||||||
|
subjects = [
|
||||||
|
item
|
||||||
|
for item in subjects
|
||||||
|
if query in _search_text(item).casefold()
|
||||||
|
]
|
||||||
|
offset = _cursor_offset(request.cursor)
|
||||||
|
selected = tuple(subjects[offset : offset + request.limit])
|
||||||
|
next_offset = offset + len(selected)
|
||||||
|
has_more = next_offset < len(subjects)
|
||||||
|
return SemanticDocumentationSubjectPage(
|
||||||
|
subjects=selected,
|
||||||
|
next_cursor=str(next_offset) if has_more else None,
|
||||||
|
has_more=has_more,
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_subject(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
reference: SemanticDocumentationSubjectReference,
|
||||||
|
) -> SemanticDocumentationSubjectResolution | None:
|
||||||
|
actor = _principal(principal, tenant_id=reference.tenant_id)
|
||||||
|
if (
|
||||||
|
reference.module_id != self.module_id
|
||||||
|
or reference.subject_kind != SUBJECT_KIND
|
||||||
|
or actor is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
db = _session(session)
|
||||||
|
definition = db.scalar(
|
||||||
|
select(WorkflowDefinition).where(
|
||||||
|
WorkflowDefinition.id == reference.subject_id,
|
||||||
|
or_(
|
||||||
|
WorkflowDefinition.tenant_id == reference.tenant_id,
|
||||||
|
WorkflowDefinition.tenant_id.is_(None),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if definition is None or not self._can_view(definition, actor):
|
||||||
|
return None
|
||||||
|
if definition.deleted_at is not None:
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="missing",
|
||||||
|
reason_code="definition_deleted",
|
||||||
|
)
|
||||||
|
revision = get_definition_revision(db, definition=definition)
|
||||||
|
lineage = _lineage_state(db, definition)
|
||||||
|
descriptor = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in _descriptors(
|
||||||
|
definition,
|
||||||
|
revision,
|
||||||
|
lineage,
|
||||||
|
tenant_id=reference.tenant_id,
|
||||||
|
)
|
||||||
|
if item.reference.stable_key == reference.stable_key
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if descriptor is None:
|
||||||
|
anchor = reference.anchor
|
||||||
|
reason = "subject_missing"
|
||||||
|
if anchor is not None and anchor.kind == "step":
|
||||||
|
reason = (
|
||||||
|
"step_deleted"
|
||||||
|
if anchor.id in lineage.historical_node_ids
|
||||||
|
else "step_missing"
|
||||||
|
)
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="missing",
|
||||||
|
reason_code=reason,
|
||||||
|
)
|
||||||
|
changed = any(
|
||||||
|
expected is not None and expected != actual
|
||||||
|
for expected, actual in (
|
||||||
|
(reference.observed_revision, descriptor.reference.observed_revision),
|
||||||
|
(
|
||||||
|
reference.observed_fingerprint,
|
||||||
|
descriptor.reference.observed_fingerprint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return SemanticDocumentationSubjectResolution(
|
||||||
|
requested_reference=reference,
|
||||||
|
availability="changed" if changed else "available",
|
||||||
|
subject=descriptor,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _can_view(
|
||||||
|
self,
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
) -> bool:
|
||||||
|
return definition_decision(
|
||||||
|
definition,
|
||||||
|
principal=principal,
|
||||||
|
registry=self._registry,
|
||||||
|
action="view",
|
||||||
|
).allowed
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptors(
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
revision: WorkflowDefinitionRevision,
|
||||||
|
lineage: _LineageState,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> tuple[SemanticDocumentationSubjectDescriptor, ...]:
|
||||||
|
route = f"/workflow?definition={quote(definition.id, safe='')}"
|
||||||
|
definition_fingerprint = semantic_documentation_fingerprint(
|
||||||
|
{
|
||||||
|
"revision": definition.current_revision,
|
||||||
|
"content_hash": revision.content_hash,
|
||||||
|
"name": definition.name,
|
||||||
|
"description": definition.description,
|
||||||
|
"status": definition.status,
|
||||||
|
"active_revision": definition.active_revision,
|
||||||
|
"scope_type": definition.scope_type,
|
||||||
|
"definition_kind": definition.definition_kind,
|
||||||
|
"allow_start": definition.allow_start,
|
||||||
|
"allow_reuse": definition.allow_reuse,
|
||||||
|
"allow_automation": definition.allow_automation,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = [
|
||||||
|
SemanticDocumentationSubjectDescriptor(
|
||||||
|
reference=_reference(
|
||||||
|
definition,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
revision=str(definition.current_revision),
|
||||||
|
fingerprint=definition_fingerprint,
|
||||||
|
),
|
||||||
|
labels={"en": definition.name},
|
||||||
|
descriptions=(
|
||||||
|
{"en": definition.description} if definition.description else {}
|
||||||
|
),
|
||||||
|
route=route,
|
||||||
|
# The provider already applies the engine's READ-or-ADMIN and
|
||||||
|
# per-definition governance decision. The descriptor contract
|
||||||
|
# represents an AND-only scope list, so it cannot restate that
|
||||||
|
# disjunction without incorrectly excluding administrators.
|
||||||
|
required_scopes=(),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
|
||||||
|
nodes = tuple(
|
||||||
|
item for item in graph.get("nodes", ()) if isinstance(item, Mapping)
|
||||||
|
)
|
||||||
|
edges = tuple(
|
||||||
|
item for item in graph.get("edges", ()) if isinstance(item, Mapping)
|
||||||
|
)
|
||||||
|
node_by_id = {str(item.get("id")): item for item in nodes if item.get("id")}
|
||||||
|
for node_id, node in node_by_id.items():
|
||||||
|
identity = lineage.node_ids[node_id]
|
||||||
|
fingerprint = _node_fingerprint(node, edges)
|
||||||
|
label = str(node.get("label") or node.get("type") or node_id)[:300]
|
||||||
|
breadcrumbs = [
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=definition.name,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
parent_id = str(node.get("parent_id") or "")
|
||||||
|
parent = node_by_id.get(parent_id)
|
||||||
|
if parent is not None and parent_id in lineage.node_ids:
|
||||||
|
breadcrumbs.append(
|
||||||
|
SemanticDocumentationBreadcrumb(
|
||||||
|
label=str(parent.get("label") or parent_id)[:300],
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.id,
|
||||||
|
anchor=SemanticDocumentationSubjectAnchor(
|
||||||
|
kind="step",
|
||||||
|
id=lineage.node_ids[parent_id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result.append(
|
||||||
|
SemanticDocumentationSubjectDescriptor(
|
||||||
|
reference=_reference(
|
||||||
|
definition,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
anchor=SemanticDocumentationSubjectAnchor(
|
||||||
|
kind="step", id=identity
|
||||||
|
),
|
||||||
|
revision=fingerprint,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
),
|
||||||
|
labels={"en": label},
|
||||||
|
descriptions={
|
||||||
|
"en": f"Configured {str(node.get('type') or 'workflow step')[:200]} step."
|
||||||
|
},
|
||||||
|
breadcrumbs=tuple(breadcrumbs),
|
||||||
|
route=route,
|
||||||
|
route_anchor=_route_anchor(node_id),
|
||||||
|
required_scopes=(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _reference(
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
revision: str,
|
||||||
|
fingerprint: str,
|
||||||
|
anchor: SemanticDocumentationSubjectAnchor | None = None,
|
||||||
|
) -> SemanticDocumentationSubjectReference:
|
||||||
|
return SemanticDocumentationSubjectReference(
|
||||||
|
module_id="workflow",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
subject_kind=SUBJECT_KIND,
|
||||||
|
subject_id=definition.id,
|
||||||
|
anchor=anchor,
|
||||||
|
observed_revision=revision,
|
||||||
|
observed_fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lineage_state(
|
||||||
|
session: Session,
|
||||||
|
definition: WorkflowDefinition,
|
||||||
|
) -> _LineageState:
|
||||||
|
revisions = sorted(
|
||||||
|
list_definition_revisions(session, definition=definition),
|
||||||
|
key=lambda item: item.revision,
|
||||||
|
)
|
||||||
|
active: dict[str, str] = {}
|
||||||
|
historical: set[str] = set()
|
||||||
|
for revision in revisions:
|
||||||
|
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
|
||||||
|
node_ids = {
|
||||||
|
str(item.get("id"))
|
||||||
|
for item in graph.get("nodes", ())
|
||||||
|
if isinstance(item, Mapping) and item.get("id")
|
||||||
|
}
|
||||||
|
active = {key: value for key, value in active.items() if key in node_ids}
|
||||||
|
for node_id in sorted(node_ids):
|
||||||
|
active.setdefault(node_id, _lineage_id(revision.id, node_id))
|
||||||
|
historical.add(active[node_id])
|
||||||
|
return _LineageState(
|
||||||
|
node_ids=active,
|
||||||
|
historical_node_ids=frozenset(historical),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _node_fingerprint(
|
||||||
|
node: Mapping[str, object],
|
||||||
|
edges: tuple[Mapping[str, object], ...],
|
||||||
|
) -> str:
|
||||||
|
node_id = str(node.get("id") or "")
|
||||||
|
connected = [
|
||||||
|
{
|
||||||
|
"id": edge.get("id"),
|
||||||
|
"type": edge.get("type"),
|
||||||
|
"label": edge.get("label"),
|
||||||
|
"source": edge.get("source"),
|
||||||
|
"target": edge.get("target"),
|
||||||
|
"source_port": edge.get("source_port"),
|
||||||
|
"target_port": edge.get("target_port"),
|
||||||
|
"config": redact_secret_values(edge.get("config") or {}),
|
||||||
|
}
|
||||||
|
for edge in edges
|
||||||
|
if node_id in {str(edge.get("source") or ""), str(edge.get("target") or "")}
|
||||||
|
]
|
||||||
|
connected.sort(key=lambda item: str(item["id"] or ""))
|
||||||
|
return semantic_documentation_fingerprint(
|
||||||
|
{
|
||||||
|
"type": node.get("type"),
|
||||||
|
"label": node.get("label"),
|
||||||
|
"parent_id": node.get("parent_id"),
|
||||||
|
"process_id": node.get("process_id"),
|
||||||
|
"config": redact_secret_values(node.get("config") or {}),
|
||||||
|
"connections": connected,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lineage_id(revision_id: str, node_id: str) -> str:
|
||||||
|
digest = hashlib.sha256(f"{revision_id}\x1f{node_id}".encode()).hexdigest()
|
||||||
|
return f"step-{digest[:40]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _route_anchor(node_id: str) -> str:
|
||||||
|
route_anchor = f"workflow-node-{node_id}"
|
||||||
|
if len(route_anchor) <= 255:
|
||||||
|
return route_anchor
|
||||||
|
digest = hashlib.sha256(node_id.encode()).hexdigest()
|
||||||
|
return f"workflow-node-{digest[:40]}"
|
||||||
|
|
||||||
|
|
||||||
|
def _search_text(item: SemanticDocumentationSubjectDescriptor) -> str:
|
||||||
|
return " ".join(
|
||||||
|
(
|
||||||
|
item.reference.subject_id,
|
||||||
|
*item.labels.values(),
|
||||||
|
*item.descriptions.values(),
|
||||||
|
*(breadcrumb.label for breadcrumb in item.breadcrumbs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> ApiPrincipal | None:
|
||||||
|
if not isinstance(principal, ApiPrincipal) or principal.tenant_id != tenant_id:
|
||||||
|
return None
|
||||||
|
if not (
|
||||||
|
principal.has(DEFINITION_READ_SCOPE)
|
||||||
|
or principal.has(ADMIN_SCOPE)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return principal
|
||||||
|
|
||||||
|
|
||||||
|
def _cursor_offset(value: str | None) -> int:
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
if not value.isdigit() or int(value) < 0:
|
||||||
|
raise ValueError("Workflow semantic subject cursor is invalid.")
|
||||||
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Workflow semantic subjects require a SQLAlchemy session.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SUBJECT_KIND",
|
||||||
|
"WorkflowSemanticDocumentationSubjectProvider",
|
||||||
|
]
|
||||||
+27
-1
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from govoplan_workflow.backend.manifest import get_manifest
|
from govoplan_workflow.backend.manifest import get_manifest
|
||||||
@@ -22,12 +23,37 @@ class WorkflowManifestTests(unittest.TestCase):
|
|||||||
self.assertEqual((), manifest.permissions)
|
self.assertEqual((), manifest.permissions)
|
||||||
self.assertIsNone(manifest.route_factory)
|
self.assertIsNone(manifest.route_factory)
|
||||||
self.assertIsNone(manifest.migration_spec)
|
self.assertIsNone(manifest.migration_spec)
|
||||||
self.assertEqual({}, manifest.capability_factories)
|
self.assertIn("documentation.semantic_subjects.workflow", manifest.capability_factories)
|
||||||
|
self.assertIn("docs", manifest.optional_dependencies)
|
||||||
|
self.assertIn(
|
||||||
|
"documentation.semantic_subjects.workflow",
|
||||||
|
{item.name for item in manifest.provides_interfaces},
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"workflow.semantic-documentation",
|
||||||
|
{topic.id for topic in manifest.documentation},
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"@govoplan/workflow-webui",
|
"@govoplan/workflow-webui",
|
||||||
manifest.frontend.package_name if manifest.frontend else None,
|
manifest.frontend.package_name if manifest.frontend else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_editor_exposes_semantic_help_without_replacing_static_help(self) -> None:
|
||||||
|
root = Path(__file__).parents[1]
|
||||||
|
page = (root / "webui/src/features/workflow/WorkflowPage.tsx").read_text()
|
||||||
|
inspector = (
|
||||||
|
root / "webui/src/features/workflow/WorkflowInspector.tsx"
|
||||||
|
).read_text()
|
||||||
|
|
||||||
|
self.assertIn("workflow.semantic-documentation", {
|
||||||
|
topic.id for topic in get_manifest().documentation
|
||||||
|
})
|
||||||
|
self.assertIn('"semantic"', page)
|
||||||
|
self.assertIn('"workflow_definition"', page)
|
||||||
|
self.assertIn('target="_blank"', page)
|
||||||
|
self.assertIn('target="_blank"', inspector)
|
||||||
|
self.assertIn("workflow-node-${nodeId}", inspector)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.semantic_documentation import (
|
||||||
|
SemanticDocumentationSubjectQuery,
|
||||||
|
)
|
||||||
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_workflow.backend.semantic_subjects import (
|
||||||
|
WorkflowSemanticDocumentationSubjectProvider,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.schemas import (
|
||||||
|
WorkflowDefinitionCreateRequest,
|
||||||
|
WorkflowDefinitionUpdateRequest,
|
||||||
|
WorkflowEdge,
|
||||||
|
WorkflowGraph,
|
||||||
|
WorkflowNode,
|
||||||
|
WorkflowPosition,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.service import (
|
||||||
|
create_definition,
|
||||||
|
delete_definition,
|
||||||
|
update_definition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def principal(
|
||||||
|
*,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
scopes: frozenset[str] = frozenset({"workflow:definition:read"}),
|
||||||
|
) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="author-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scopes=scopes,
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=object(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def graph(
|
||||||
|
*,
|
||||||
|
activity_label: str = "Review",
|
||||||
|
activity_x: float = 280,
|
||||||
|
include_activity: bool = True,
|
||||||
|
) -> WorkflowGraph:
|
||||||
|
nodes = [
|
||||||
|
WorkflowNode(
|
||||||
|
id="start",
|
||||||
|
type="workflow.start.manual",
|
||||||
|
label="Start",
|
||||||
|
position=WorkflowPosition(x=40, y=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if include_activity:
|
||||||
|
nodes.append(
|
||||||
|
WorkflowNode(
|
||||||
|
id="activity",
|
||||||
|
type="workflow.activity",
|
||||||
|
label=activity_label,
|
||||||
|
position=WorkflowPosition(x=activity_x, y=100),
|
||||||
|
config={
|
||||||
|
"instructions": "Check the submitted evidence.",
|
||||||
|
"api_secret": "must-not-leak",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
nodes.append(
|
||||||
|
WorkflowNode(
|
||||||
|
id="complete",
|
||||||
|
type="workflow.end.completed",
|
||||||
|
label="Completed",
|
||||||
|
position=WorkflowPosition(x=520, y=100),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
edges = []
|
||||||
|
if include_activity:
|
||||||
|
edges.extend(
|
||||||
|
(
|
||||||
|
WorkflowEdge(id="start-activity", source="start", target="activity"),
|
||||||
|
WorkflowEdge(
|
||||||
|
id="activity-complete",
|
||||||
|
source="activity",
|
||||||
|
target="complete",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
edges.append(
|
||||||
|
WorkflowEdge(id="start-complete", source="start", target="complete")
|
||||||
|
)
|
||||||
|
return WorkflowGraph(nodes=nodes, edges=edges)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowSemanticSubjectTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(
|
||||||
|
self.engine,
|
||||||
|
tables=(
|
||||||
|
WorkflowDefinition.__table__,
|
||||||
|
WorkflowDefinitionRevision.__table__,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session = Session(self.engine)
|
||||||
|
self.principal = principal()
|
||||||
|
self.provider = WorkflowSemanticDocumentationSubjectProvider(None)
|
||||||
|
self.definition = create_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
actor_id="author-1",
|
||||||
|
payload=WorkflowDefinitionCreateRequest(
|
||||||
|
name="Resident permit review",
|
||||||
|
description="Review an application before issuing the permit.",
|
||||||
|
graph=graph(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def subjects(self):
|
||||||
|
return self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
limit=200,
|
||||||
|
),
|
||||||
|
).subjects
|
||||||
|
|
||||||
|
def activity(self):
|
||||||
|
return next(
|
||||||
|
item
|
||||||
|
for item in self.subjects()
|
||||||
|
if item.route_anchor == "workflow-node-activity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def revise(self, updated_graph: WorkflowGraph) -> None:
|
||||||
|
update_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=self.definition.id,
|
||||||
|
actor_id="author-1",
|
||||||
|
payload=WorkflowDefinitionUpdateRequest(
|
||||||
|
name=self.definition.name,
|
||||||
|
description=self.definition.description,
|
||||||
|
graph=updated_graph,
|
||||||
|
expected_revision=self.definition.current_revision,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def test_exposes_safe_definition_and_step_descriptors(self) -> None:
|
||||||
|
subjects = self.subjects()
|
||||||
|
self.assertEqual(4, len(subjects))
|
||||||
|
definition = next(item for item in subjects if item.reference.anchor is None)
|
||||||
|
activity = self.activity()
|
||||||
|
|
||||||
|
self.assertEqual("Resident permit review", definition.labels["en"])
|
||||||
|
self.assertEqual("step", activity.reference.anchor.kind)
|
||||||
|
self.assertTrue(activity.reference.anchor.id.startswith("step-"))
|
||||||
|
self.assertEqual("/workflow?definition=" + self.definition.id, activity.route)
|
||||||
|
payload = activity.to_dict()
|
||||||
|
self.assertNotIn("config", payload)
|
||||||
|
self.assertNotIn("must-not-leak", str(payload))
|
||||||
|
|
||||||
|
def test_lineage_survives_layout_and_label_changes_with_review_fingerprint(self) -> None:
|
||||||
|
before = self.activity().reference
|
||||||
|
self.revise(graph(activity_x=900))
|
||||||
|
after_layout = self.activity().reference
|
||||||
|
self.assertEqual(before.stable_key, after_layout.stable_key)
|
||||||
|
self.assertEqual(
|
||||||
|
before.observed_fingerprint,
|
||||||
|
after_layout.observed_fingerprint,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.revise(graph(activity_x=900, activity_label="Assess evidence"))
|
||||||
|
after_label = self.activity().reference
|
||||||
|
self.assertEqual(before.stable_key, after_label.stable_key)
|
||||||
|
self.assertNotEqual(
|
||||||
|
before.observed_fingerprint,
|
||||||
|
after_label.observed_fingerprint,
|
||||||
|
)
|
||||||
|
resolution = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=before,
|
||||||
|
)
|
||||||
|
self.assertEqual("changed", resolution.availability)
|
||||||
|
|
||||||
|
def test_deleted_and_recreated_step_id_gets_new_lineage(self) -> None:
|
||||||
|
old = self.activity().reference
|
||||||
|
self.revise(graph(include_activity=False))
|
||||||
|
deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=old,
|
||||||
|
)
|
||||||
|
self.assertEqual("missing", deleted.availability)
|
||||||
|
self.assertEqual("step_deleted", deleted.reason_code)
|
||||||
|
|
||||||
|
self.revise(graph(activity_label="Recreated review"))
|
||||||
|
recreated = self.activity().reference
|
||||||
|
self.assertNotEqual(old.stable_key, recreated.stable_key)
|
||||||
|
still_deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=old,
|
||||||
|
)
|
||||||
|
self.assertEqual("step_deleted", still_deleted.reason_code)
|
||||||
|
|
||||||
|
def test_resolution_rechecks_scope_tenant_governance_and_deletion(self) -> None:
|
||||||
|
reference = self.activity().reference
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
principal(scopes=frozenset()),
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
principal(tenant_id="tenant-2"),
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
admin = principal(scopes=frozenset({"workflow:instance:admin"}))
|
||||||
|
self.assertIsNotNone(
|
||||||
|
self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
admin,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_definition(
|
||||||
|
self.session,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
definition_id=self.definition.id,
|
||||||
|
actor_id="author-1",
|
||||||
|
)
|
||||||
|
self.session.commit()
|
||||||
|
deleted = self.provider.resolve_subject(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
reference=reference,
|
||||||
|
)
|
||||||
|
self.assertEqual("missing", deleted.availability)
|
||||||
|
self.assertEqual("definition_deleted", deleted.reason_code)
|
||||||
|
|
||||||
|
def test_query_filters_and_pages_without_docs_runtime(self) -> None:
|
||||||
|
page = self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
query="review",
|
||||||
|
limit=1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(page.subjects))
|
||||||
|
self.assertTrue(page.has_more)
|
||||||
|
second = self.provider.list_subjects(
|
||||||
|
self.session,
|
||||||
|
self.principal,
|
||||||
|
request=SemanticDocumentationSubjectQuery(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
query="review",
|
||||||
|
limit=1,
|
||||||
|
cursor=page.next_cursor,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(second.subjects))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/workflow-webui",
|
"name": "@govoplan/workflow-webui",
|
||||||
"version": "0.1.17",
|
"version": "0.1.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
"test:interface-pattern": "node scripts/test-interface-pattern.mjs"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.17",
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type Edge,
|
type Edge,
|
||||||
type ReactFlowInstance
|
type ReactFlowInstance
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
|
import { StatePanel } from "@govoplan/core-webui";
|
||||||
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
|
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
|
||||||
import type {
|
import type {
|
||||||
WorkflowDiagnostic,
|
WorkflowDiagnostic,
|
||||||
@@ -211,7 +212,7 @@ export default function WorkflowCanvas({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="workflow-canvas"
|
className="definition-graph-canvas workflow-canvas"
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (
|
if (
|
||||||
event.dataTransfer.types.includes(
|
event.dataTransfer.types.includes(
|
||||||
@@ -320,7 +321,7 @@ export default function WorkflowCanvas({
|
|||||||
<Controls showInteractive={false} />
|
<Controls showInteractive={false} />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
{!graph.nodes.length ? (
|
{!graph.nodes.length ? (
|
||||||
<div className="workflow-canvas-empty">Drop a start node here</div>
|
<StatePanel className="definition-graph-canvas-empty" size="fill" description="Drop a start node here" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Trash2 } from "lucide-react";
|
import { BookOpen, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
ActionToolbar,
|
||||||
Button,
|
Button,
|
||||||
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
FormField,
|
FormField,
|
||||||
ReferenceMultiSelect,
|
ReferenceMultiSelect,
|
||||||
|
StatePanel,
|
||||||
useViewSurfaces,
|
useViewSurfaces,
|
||||||
type ReferenceOptionProvider
|
type ReferenceOptionProvider
|
||||||
} from "@govoplan/core-webui";
|
} from "@govoplan/core-webui";
|
||||||
@@ -19,6 +22,8 @@ export default function WorkflowInspector({
|
|||||||
edge,
|
edge,
|
||||||
nodeLibrary,
|
nodeLibrary,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
semanticDefinitionId,
|
||||||
|
semanticStepAvailable,
|
||||||
onChange,
|
onChange,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdgeChange,
|
onEdgeChange,
|
||||||
@@ -28,6 +33,8 @@ export default function WorkflowInspector({
|
|||||||
edge: WorkflowGraphEdge | null;
|
edge: WorkflowGraphEdge | null;
|
||||||
nodeLibrary: WorkflowNodeType[];
|
nodeLibrary: WorkflowNodeType[];
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
semanticDefinitionId: string | null;
|
||||||
|
semanticStepAvailable: boolean;
|
||||||
onChange: (node: WorkflowGraphNode) => void;
|
onChange: (node: WorkflowGraphNode) => void;
|
||||||
onDelete: (nodeId: string) => void;
|
onDelete: (nodeId: string) => void;
|
||||||
onEdgeChange: (edge: WorkflowGraphEdge) => void;
|
onEdgeChange: (edge: WorkflowGraphEdge) => void;
|
||||||
@@ -87,7 +94,7 @@ export default function WorkflowInspector({
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<aside className="workflow-inspector" aria-label="Flow inspector">
|
<aside className="workflow-inspector" aria-label="Flow inspector">
|
||||||
<div className="workflow-panel-heading">
|
<ActionToolbar surface="section-header" className="workflow-panel-heading">
|
||||||
<span>
|
<span>
|
||||||
<strong>Flow</strong>
|
<strong>Flow</strong>
|
||||||
<small>{edge.type.replace(/^bpmn\./, "")}</small>
|
<small>{edge.type.replace(/^bpmn\./, "")}</small>
|
||||||
@@ -102,7 +109,7 @@ export default function WorkflowInspector({
|
|||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<div className="workflow-inspector-fields">
|
<div className="workflow-inspector-fields">
|
||||||
<FormField label="Flow type">
|
<FormField label="Flow type">
|
||||||
<select
|
<select
|
||||||
@@ -184,8 +191,8 @@ export default function WorkflowInspector({
|
|||||||
if (!node) {
|
if (!node) {
|
||||||
return (
|
return (
|
||||||
<aside className="workflow-inspector" aria-label="Node inspector">
|
<aside className="workflow-inspector" aria-label="Node inspector">
|
||||||
<div className="workflow-panel-heading"><strong>Inspector</strong></div>
|
<ActionToolbar surface="section-header" className="workflow-panel-heading"><strong>Inspector</strong></ActionToolbar>
|
||||||
<div className="workflow-inspector-empty">No node selected</div>
|
<StatePanel size="compact" description="No node selected" />
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -200,11 +207,29 @@ export default function WorkflowInspector({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="workflow-inspector" aria-label="Node inspector">
|
<aside className="workflow-inspector" aria-label="Node inspector">
|
||||||
<div className="workflow-panel-heading">
|
<ActionToolbar surface="section-header" className="workflow-panel-heading">
|
||||||
<span>
|
<span>
|
||||||
<strong>Inspector</strong>
|
<strong>Inspector</strong>
|
||||||
<small>{definition?.label ?? node.type}</small>
|
<small>{definition?.label ?? node.type}</small>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="workflow-inspector-actions">
|
||||||
|
{semanticDefinitionId && semanticStepAvailable ? <>
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{
|
||||||
|
contextId: semanticHelpContext(semanticDefinitionId, node.id),
|
||||||
|
documentationType: "user"
|
||||||
|
}}
|
||||||
|
label="Open step meaning"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary"
|
||||||
|
href={semanticAuthoringHref(semanticDefinitionId, node.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<BookOpen size={15} aria-hidden="true" /> Document meaning
|
||||||
|
</a>
|
||||||
|
</> : null}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="workflow-inspector-delete"
|
className="workflow-inspector-delete"
|
||||||
@@ -215,7 +240,8 @@ export default function WorkflowInspector({
|
|||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</span>
|
||||||
|
</ActionToolbar>
|
||||||
<div className="workflow-inspector-fields">
|
<div className="workflow-inspector-fields">
|
||||||
{error ? (
|
{error ? (
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
@@ -330,3 +356,23 @@ function stringList(value: unknown): string[] {
|
|||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function semanticHelpContext(definitionId: string, nodeId: string): string {
|
||||||
|
return [
|
||||||
|
"semantic",
|
||||||
|
"workflow",
|
||||||
|
"workflow_definition",
|
||||||
|
definitionId,
|
||||||
|
`workflow-node-${nodeId}`
|
||||||
|
].join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticAuthoringHref(definitionId: string, nodeId: string): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
module: "workflow",
|
||||||
|
subjectKind: "workflow_definition",
|
||||||
|
subjectId: definitionId,
|
||||||
|
routeAnchor: `workflow-node-${nodeId}`
|
||||||
|
});
|
||||||
|
return `/docs/semantic?${params}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
type Node,
|
type Node,
|
||||||
type NodeProps
|
type NodeProps
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
|
import { DefinitionNodeIcon } from "@govoplan/core-webui";
|
||||||
import type { WorkflowNodeType } from "../../api/workflow";
|
import type { WorkflowNodeType } from "../../api/workflow";
|
||||||
|
|
||||||
export type WorkflowFlowNodeData = {
|
export type WorkflowFlowNodeData = {
|
||||||
@@ -103,6 +104,7 @@ const iconByName: Record<string, LucideIcon> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function WorkflowNode({
|
export default function WorkflowNode({
|
||||||
|
id,
|
||||||
data,
|
data,
|
||||||
selected,
|
selected,
|
||||||
isConnectable
|
isConnectable
|
||||||
@@ -113,6 +115,7 @@ export default function WorkflowNode({
|
|||||||
const outputPorts = data.definition.output_ports;
|
const outputPorts = data.definition.output_ports;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={`workflow-node-${id}`}
|
||||||
className={[
|
className={[
|
||||||
"workflow-node",
|
"workflow-node",
|
||||||
`workflow-node-${data.definition.category}`,
|
`workflow-node-${data.definition.category}`,
|
||||||
@@ -127,13 +130,13 @@ export default function WorkflowNode({
|
|||||||
id={port.id}
|
id={port.id}
|
||||||
type="target"
|
type="target"
|
||||||
position={Position.Left}
|
position={Position.Left}
|
||||||
className="workflow-node-handle"
|
className="definition-node-handle"
|
||||||
style={{ top: portPosition(index, inputPorts.length) }}
|
style={{ top: portPosition(index, inputPorts.length) }}
|
||||||
isConnectable={isConnectable}
|
isConnectable={isConnectable}
|
||||||
title={port.label}
|
title={port.label}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<span className="workflow-node-icon"><Icon size={17} /></span>
|
<DefinitionNodeIcon className="workflow-node-icon"><Icon size={17} /></DefinitionNodeIcon>
|
||||||
<span className="workflow-node-copy">
|
<span className="workflow-node-copy">
|
||||||
<strong>{data.label}</strong>
|
<strong>{data.label}</strong>
|
||||||
<small>{data.definition.label}</small>
|
<small>{data.definition.label}</small>
|
||||||
@@ -144,7 +147,7 @@ export default function WorkflowNode({
|
|||||||
id={port.id}
|
id={port.id}
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Right}
|
position={Position.Right}
|
||||||
className="workflow-node-handle"
|
className="definition-node-handle"
|
||||||
style={{ top: portPosition(index, outputPorts.length) }}
|
style={{ top: portPosition(index, outputPorts.length) }}
|
||||||
isConnectable={isConnectable}
|
isConnectable={isConnectable}
|
||||||
title={port.label}
|
title={port.label}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Archive,
|
Archive,
|
||||||
|
BookOpen,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CopyPlus,
|
CopyPlus,
|
||||||
Download,
|
Download,
|
||||||
GitFork,
|
GitFork,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Save,
|
Save,
|
||||||
Settings2,
|
Settings2,
|
||||||
@@ -23,18 +23,32 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ReactFlowProvider } from "@xyflow/react";
|
import { ReactFlowProvider } from "@xyflow/react";
|
||||||
import { useSearchParams } from "react-router";
|
import { useSearchParams } from "react-router";
|
||||||
import {
|
import { ActionToolbar,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
ContentGrid,
|
||||||
|
ContentSection,
|
||||||
Dialog,
|
Dialog,
|
||||||
DocumentationHelpLink,
|
DocumentationHelpLink,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
DefinitionPalette,
|
||||||
|
DefinitionPaletteGroup,
|
||||||
|
DefinitionPaletteItem,
|
||||||
|
FilterBar,
|
||||||
|
FloatingStatus,
|
||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
ReferenceSelect,
|
ReferenceSelect,
|
||||||
|
SelectionList,
|
||||||
|
SelectionListItem,
|
||||||
|
SelectionListItemContent,
|
||||||
|
StatePanel,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
ToggleSwitch,
|
ToggleSwitch,
|
||||||
|
WorkspaceActionBar,
|
||||||
|
WorkspaceFrame,
|
||||||
|
WorkspaceLayout,
|
||||||
hasScope,
|
hasScope,
|
||||||
isApiError,
|
isApiError,
|
||||||
useUnsavedChanges,
|
useUnsavedChanges,
|
||||||
@@ -98,7 +112,7 @@ export default function WorkflowPage({
|
|||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
auth: AuthInfo;
|
auth: AuthInfo;
|
||||||
}) {
|
}) {
|
||||||
const { requestNavigation } = useUnsavedChanges();
|
const { requestDiscard, requestNavigation } = useUnsavedChanges();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const requestedDefinitionId = searchParams.get("definition");
|
const requestedDefinitionId = searchParams.get("definition");
|
||||||
const requestedRunId = searchParams.get("run");
|
const requestedRunId = searchParams.get("run");
|
||||||
@@ -582,23 +596,24 @@ export default function WorkflowPage({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="workflow-page">
|
<WorkspaceFrame as="main" height="viewport" surface="plain" className="workflow-page" label="Workflow workspace">
|
||||||
<div className="workflow-shell">
|
<WorkspaceLayout
|
||||||
<aside className="workflow-definition-panel">
|
variant="split"
|
||||||
<div className="workflow-panel-toolbar">
|
primarySize="compact"
|
||||||
|
surface="contained"
|
||||||
|
primaryScrollable={false}
|
||||||
|
contentScrollable={false}
|
||||||
|
primaryLabel="Workflows"
|
||||||
|
contentLabel="Workflow editor"
|
||||||
|
contentClassName="workflow-workspace"
|
||||||
|
primary={<>
|
||||||
|
<WorkspaceActionBar
|
||||||
|
scope="collection-pane"
|
||||||
|
variant="collection"
|
||||||
|
refreshable
|
||||||
|
reloadAction={{ onReload: () => void reload(draft?.id), loading: loading || working }}
|
||||||
|
contextActions={<>
|
||||||
<strong>Workflows</strong>
|
<strong>Workflows</strong>
|
||||||
<span className="workflow-toolbar-actions">
|
|
||||||
<DocumentationHelpLink
|
|
||||||
reference={{ topicId: "workflow.editor", documentationType: "user" }}
|
|
||||||
label="Open Workflow documentation"
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label="Refresh"
|
|
||||||
icon={<RefreshCw size={16} />}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => void reload(draft?.id)}
|
|
||||||
disabled={loading || working}
|
|
||||||
/>
|
|
||||||
{hasScope(auth, "workflow:instance:admin") ? (
|
{hasScope(auth, "workflow:instance:admin") ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Reconcile module standards"
|
label="Reconcile module standards"
|
||||||
@@ -608,17 +623,21 @@ export default function WorkflowPage({
|
|||||||
disabled={loading || working}
|
disabled={loading || working}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<IconButton
|
</>}
|
||||||
|
helpAction={<DocumentationHelpLink
|
||||||
|
reference={{ topicId: "workflow.editor", documentationType: "user" }}
|
||||||
|
label="Open Workflow documentation"
|
||||||
|
/>}
|
||||||
|
createAction={<IconButton
|
||||||
label="New workflow"
|
label="New workflow"
|
||||||
icon={<Plus size={17} />}
|
icon={<Plus size={17} />}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={createNew}
|
onClick={createNew}
|
||||||
disabled={!canWrite}
|
disabled={!canWrite}
|
||||||
disabledReason={!canWrite ? "Workflow definition write permission is required." : undefined}
|
disabledReason={!canWrite ? "Workflow definition write permission is required." : undefined}
|
||||||
|
/>}
|
||||||
/>
|
/>
|
||||||
</span>
|
<FilterBar surface="panel">
|
||||||
</div>
|
|
||||||
<div className="workflow-definition-search">
|
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
value={search}
|
value={search}
|
||||||
@@ -626,53 +645,48 @@ export default function WorkflowPage({
|
|||||||
placeholder="Search workflows"
|
placeholder="Search workflows"
|
||||||
aria-label="Search workflows"
|
aria-label="Search workflows"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FilterBar>
|
||||||
<LoadingFrame loading={loading} className="workflow-definition-list-frame">
|
<LoadingFrame loading={loading} className="workflow-definition-list-frame">
|
||||||
<div className="workflow-definition-list">
|
<SelectionList variant="navigation" label="Workflows">
|
||||||
{visibleDefinitions.map((definition) => (
|
{visibleDefinitions.map((definition) => (
|
||||||
<button
|
<SelectionListItem
|
||||||
key={definition.id}
|
key={definition.id}
|
||||||
type="button"
|
selected={definition.id === draft?.id}
|
||||||
className={definition.id === draft?.id ? "is-selected" : ""}
|
|
||||||
onClick={() => selectDefinition(definition)}
|
onClick={() => selectDefinition(definition)}
|
||||||
>
|
>
|
||||||
<span>
|
<SelectionListItemContent
|
||||||
<strong>{definition.name}</strong>
|
title={definition.name}
|
||||||
<small>
|
description={[
|
||||||
{definition.key} · revision {definition.current_revision}
|
definition.key,
|
||||||
</small>
|
`revision ${definition.current_revision}`,
|
||||||
<small>
|
definition.governance.scope_type,
|
||||||
{definition.governance.scope_type} · {definition.governance.definition_kind}
|
definition.governance.definition_kind,
|
||||||
</small>
|
definition.standard
|
||||||
{definition.standard ? (
|
? `${definition.standard.kind === "baseline" ? "Standard" : "Override"} · ${definition.standard.origin_module_id}${definition.standard.update_available ? " · update available" : ""}`
|
||||||
<small>
|
: ""
|
||||||
{definition.standard.kind === "baseline"
|
].filter(Boolean).join(" · ")}
|
||||||
? `Standard · ${definition.standard.origin_module_id}`
|
/>
|
||||||
: `Override · ${definition.standard.origin_module_id}`}
|
|
||||||
{definition.standard.update_available ? " · update available" : ""}
|
|
||||||
</small>
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={definition.status}
|
status={definition.status}
|
||||||
label={definition.status}
|
label={definition.status}
|
||||||
/>
|
/>
|
||||||
</button>
|
</SelectionListItem>
|
||||||
))}
|
))}
|
||||||
{!visibleDefinitions.length ? (
|
{!visibleDefinitions.length ? (
|
||||||
<div className="workflow-definition-empty">
|
<StatePanel size="compact" description="No workflow definitions" />
|
||||||
No workflow definitions
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</SelectionList>
|
||||||
</LoadingFrame>
|
</LoadingFrame>
|
||||||
</aside>
|
</>}
|
||||||
|
>
|
||||||
<section className="workflow-workspace">
|
|
||||||
{draft && displayedGraph ? (
|
{draft && displayedGraph ? (
|
||||||
<>
|
<>
|
||||||
<div className="workflow-workspace-toolbar">
|
<WorkspaceActionBar
|
||||||
<span className="workflow-identity-fields">
|
scope="editor-pane"
|
||||||
|
variant="editor"
|
||||||
|
state={working ? "saving" : dirty && !draft.name.trim() ? "invalid" : dirty ? "dirty" : "clean"}
|
||||||
|
className="workflow-workspace-toolbar"
|
||||||
|
contextActions={<span className="workflow-identity-fields">
|
||||||
<input
|
<input
|
||||||
className="workflow-name-input"
|
className="workflow-name-input"
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
@@ -694,8 +708,25 @@ export default function WorkflowPage({
|
|||||||
placeholder="Description"
|
placeholder="Description"
|
||||||
aria-label="Workflow description"
|
aria-label="Workflow description"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>}
|
||||||
<span className="workflow-command-bar">
|
helpAction={draft.id ? <>
|
||||||
|
<DocumentationHelpLink
|
||||||
|
reference={{
|
||||||
|
contextId: semanticHelpContext(draft.id),
|
||||||
|
documentationType: "user"
|
||||||
|
}}
|
||||||
|
label="Open workflow meaning"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
className="btn btn-secondary"
|
||||||
|
href={semanticAuthoringHref(draft.id)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<BookOpen size={16} aria-hidden="true" /> Document meaning
|
||||||
|
</a>
|
||||||
|
</> : undefined}
|
||||||
|
primaryActions={<span className="workflow-command-bar">
|
||||||
{draft.id ? (
|
{draft.id ? (
|
||||||
<select
|
<select
|
||||||
className="workflow-revision-select"
|
className="workflow-revision-select"
|
||||||
@@ -839,16 +870,8 @@ export default function WorkflowPage({
|
|||||||
<CheckCircle2 size={16} /> Activate
|
<CheckCircle2 size={16} /> Activate
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
</span>}
|
||||||
variant="primary"
|
destructiveActions={draft.id && draft.standard?.kind !== "baseline" ? (
|
||||||
onClick={() => void saveDraft()}
|
|
||||||
disabled={
|
|
||||||
!canEdit || !dirty || working || readOnly
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Save size={16} /> Save
|
|
||||||
</Button>
|
|
||||||
{draft.id && draft.standard?.kind !== "baseline" ? (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Delete workflow"
|
label="Delete workflow"
|
||||||
icon={<Trash2 size={16} />}
|
icon={<Trash2 size={16} />}
|
||||||
@@ -856,9 +879,17 @@ export default function WorkflowPage({
|
|||||||
onClick={() => setDeleteOpen(true)}
|
onClick={() => setDeleteOpen(true)}
|
||||||
disabled={!canEdit || working}
|
disabled={!canEdit || working}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : undefined}
|
||||||
</span>
|
discardAction={{
|
||||||
</div>
|
label: <><RotateCcw size={16} /> Discard</>,
|
||||||
|
onClick: () => requestDiscard(() => void reload(draft.id))
|
||||||
|
}}
|
||||||
|
saveAction={{
|
||||||
|
label: <><Save size={16} /> Save</>,
|
||||||
|
onClick: () => void saveDraft(),
|
||||||
|
disabled: !canEdit || readOnly
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="workflow-alerts">
|
<div className="workflow-alerts">
|
||||||
{error ? (
|
{error ? (
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
@@ -872,21 +903,14 @@ export default function WorkflowPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="workflow-editor">
|
<div className="workflow-editor">
|
||||||
<aside className="workflow-palette">
|
<DefinitionPalette label="Node library" description="Drag nodes onto the canvas">
|
||||||
<div className="workflow-panel-heading">
|
|
||||||
<span>
|
|
||||||
<strong>Node library</strong>
|
|
||||||
<small>Drag nodes onto the canvas</small>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="workflow-palette-items">
|
|
||||||
{paletteGroups.map((group) => (
|
{paletteGroups.map((group) => (
|
||||||
<section key={group.category} className="workflow-palette-group">
|
<DefinitionPaletteGroup key={group.category} label={group.label}>
|
||||||
<h3>{group.label}</h3>
|
|
||||||
{group.nodes.map((nodeType) => (
|
{group.nodes.map((nodeType) => (
|
||||||
<button
|
<DefinitionPaletteItem
|
||||||
key={nodeType.type}
|
key={nodeType.type}
|
||||||
type="button"
|
icon={<GitFork size={16} />}
|
||||||
|
label={nodeType.label}
|
||||||
draggable={!graphReadOnly}
|
draggable={!graphReadOnly}
|
||||||
disabled={graphReadOnly}
|
disabled={graphReadOnly}
|
||||||
onDragStart={(event) =>
|
onDragStart={(event) =>
|
||||||
@@ -894,17 +918,12 @@ export default function WorkflowPage({
|
|||||||
}
|
}
|
||||||
onClick={() => addNodeFromPalette(nodeType.type)}
|
onClick={() => addNodeFromPalette(nodeType.type)}
|
||||||
title={nodeType.description}
|
title={nodeType.description}
|
||||||
>
|
/>
|
||||||
<GitFork size={16} />
|
|
||||||
<span>{nodeType.label}</span>
|
|
||||||
<Plus size={13} className="workflow-palette-add" />
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</section>
|
</DefinitionPaletteGroup>
|
||||||
))}
|
))}
|
||||||
</div>
|
</DefinitionPalette>
|
||||||
</aside>
|
<div className="definition-editor-surface workflow-editor-surface">
|
||||||
<div className="workflow-editor-surface">
|
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<WorkflowCanvas
|
<WorkflowCanvas
|
||||||
graph={displayedGraph}
|
graph={displayedGraph}
|
||||||
@@ -926,6 +945,15 @@ export default function WorkflowPage({
|
|||||||
edge={selectedEdge}
|
edge={selectedEdge}
|
||||||
nodeLibrary={nodeLibrary}
|
nodeLibrary={nodeLibrary}
|
||||||
readOnly={graphReadOnly}
|
readOnly={graphReadOnly}
|
||||||
|
semanticDefinitionId={draft.id || null}
|
||||||
|
semanticStepAvailable={Boolean(
|
||||||
|
draft.id
|
||||||
|
&& !historicalRevision
|
||||||
|
&& selectedNode
|
||||||
|
&& savedDraft?.graph.nodes.some(
|
||||||
|
(node) => node.id === selectedNode.id
|
||||||
|
)
|
||||||
|
)}
|
||||||
onChange={(node) => {
|
onChange={(node) => {
|
||||||
if (!draft || graphReadOnly) return;
|
if (!draft || graphReadOnly) return;
|
||||||
updateGraph(updateWorkflowGraphNode(draft.graph, node));
|
updateGraph(updateWorkflowGraphNode(draft.graph, node));
|
||||||
@@ -936,7 +964,7 @@ export default function WorkflowPage({
|
|||||||
/>
|
/>
|
||||||
{diagnostics.length ? (
|
{diagnostics.length ? (
|
||||||
<div className="workflow-diagnostics">
|
<div className="workflow-diagnostics">
|
||||||
<div className="workflow-panel-heading">
|
<ActionToolbar surface="section-header" className="workflow-panel-heading">
|
||||||
<strong>Diagnostics</strong>
|
<strong>Diagnostics</strong>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={
|
status={
|
||||||
@@ -946,7 +974,7 @@ export default function WorkflowPage({
|
|||||||
}
|
}
|
||||||
label={String(diagnostics.length)}
|
label={String(diagnostics.length)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
<div>
|
<div>
|
||||||
{diagnostics.map((item, index) => (
|
{diagnostics.map((item, index) => (
|
||||||
<button
|
<button
|
||||||
@@ -965,25 +993,21 @@ export default function WorkflowPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{working ? (
|
{working ? <FloatingStatus>Working...</FloatingStatus> : null}
|
||||||
<div className="workflow-working-indicator" role="status">
|
|
||||||
Working...
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="workflow-workspace-empty">
|
<StatePanel
|
||||||
<GitFork size={34} />
|
size="fill"
|
||||||
<strong>No workflow selected</strong>
|
icon={<GitFork size={34} />}
|
||||||
{canWrite ? (
|
title="No workflow selected"
|
||||||
|
actions={canWrite ? (
|
||||||
<Button variant="primary" onClick={createNew}>
|
<Button variant="primary" onClick={createNew}>
|
||||||
<Plus size={16} /> New workflow
|
<Plus size={16} /> New workflow
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : undefined}
|
||||||
</div>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</WorkspaceLayout>
|
||||||
</div>
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
title="Delete workflow"
|
title="Delete workflow"
|
||||||
@@ -1052,10 +1076,36 @@ export default function WorkflowPage({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</main>
|
</WorkspaceFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function semanticHelpContext(
|
||||||
|
definitionId: string,
|
||||||
|
routeAnchor?: string
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
"semantic",
|
||||||
|
"workflow",
|
||||||
|
"workflow_definition",
|
||||||
|
definitionId,
|
||||||
|
routeAnchor
|
||||||
|
].filter(Boolean).join(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function semanticAuthoringHref(
|
||||||
|
definitionId: string,
|
||||||
|
routeAnchor?: string
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
module: "workflow",
|
||||||
|
subjectKind: "workflow_definition",
|
||||||
|
subjectId: definitionId
|
||||||
|
});
|
||||||
|
if (routeAnchor) params.set("routeAnchor", routeAnchor);
|
||||||
|
return `/docs/semantic?${params}`;
|
||||||
|
}
|
||||||
|
|
||||||
function WorkflowStandardComparisonDialog({
|
function WorkflowStandardComparisonDialog({
|
||||||
open,
|
open,
|
||||||
settings,
|
settings,
|
||||||
@@ -1336,7 +1386,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="workflow-definition-toggles">
|
<ContentGrid columns={2} gap="default" collapseAt="narrow">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
label="Visible to lower scopes"
|
label="Visible to lower scopes"
|
||||||
checked={draft.inheritToLowerScopes}
|
checked={draft.inheritToLowerScopes}
|
||||||
@@ -1361,7 +1411,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
disabled={!editable || draft.executionMode === "guided"}
|
disabled={!editable || draft.executionMode === "guided"}
|
||||||
onChange={(value) => onChange({ allowAutomation: value })}
|
onChange={(value) => onChange({ allowAutomation: value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</ContentGrid>
|
||||||
{draft.governance?.automation_runtime_reason ? (
|
{draft.governance?.automation_runtime_reason ? (
|
||||||
<DismissibleAlert
|
<DismissibleAlert
|
||||||
tone="warning"
|
tone="warning"
|
||||||
@@ -1371,7 +1421,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
) : null}
|
) : null}
|
||||||
{draft.governance?.derived_from_definition_id ? (
|
{draft.governance?.derived_from_definition_id ? (
|
||||||
<section className="workflow-provenance">
|
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
|
||||||
<strong>Derived from</strong>
|
<strong>Derived from</strong>
|
||||||
<span>
|
<span>
|
||||||
{draft.governance.derived_from_definition_id}
|
{draft.governance.derived_from_definition_id}
|
||||||
@@ -1379,10 +1429,10 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
{draft.governance.derived_from_revision}
|
{draft.governance.derived_from_revision}
|
||||||
</span>
|
</span>
|
||||||
<code>{draft.governance.derived_from_hash}</code>
|
<code>{draft.governance.derived_from_hash}</code>
|
||||||
</section>
|
</ContentSection>
|
||||||
) : null}
|
) : null}
|
||||||
{provenance.length ? (
|
{provenance.length ? (
|
||||||
<section className="workflow-provenance">
|
<ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
|
||||||
<strong>Effective Policy path</strong>
|
<strong>Effective Policy path</strong>
|
||||||
{provenance.map((item, index) => (
|
{provenance.map((item, index) => (
|
||||||
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
|
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
|
||||||
@@ -1392,7 +1442,7 @@ function WorkflowDefinitionSettingsDialog({
|
|||||||
{!editable && draft.governance?.actions.edit?.reason ? (
|
{!editable && draft.governance?.actions.edit?.reason ? (
|
||||||
<small>{draft.governance.actions.edit.reason}</small>
|
<small>{draft.governance.actions.edit.reason}</small>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</ContentSection>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
XCircle
|
XCircle
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import { ActionToolbar,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
Dialog,
|
Dialog,
|
||||||
DismissibleAlert,
|
DismissibleAlert,
|
||||||
|
ContentGrid,
|
||||||
FormField,
|
FormField,
|
||||||
IconButton,
|
IconButton,
|
||||||
LoadingFrame,
|
LoadingFrame,
|
||||||
@@ -284,6 +285,13 @@ export default function WorkflowRunsDialog({
|
|||||||
const actionUrl = typeof currentStep?.handoff.action_url === "string"
|
const actionUrl = typeof currentStep?.handoff.action_url === "string"
|
||||||
? currentStep.handoff.action_url
|
? currentStep.handoff.action_url
|
||||||
: "";
|
: "";
|
||||||
|
const unavailableOptionalCapabilities = Array.isArray(
|
||||||
|
currentStep?.handoff.unavailable_optional_capabilities
|
||||||
|
)
|
||||||
|
? currentStep.handoff.unavailable_optional_capabilities.filter(
|
||||||
|
(value): value is string => typeof value === "string" && Boolean(value)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
const close = () => {
|
const close = () => {
|
||||||
dispatchWorkflowViewChanged(null);
|
dispatchWorkflowViewChanged(null);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -299,7 +307,7 @@ export default function WorkflowRunsDialog({
|
|||||||
onClose={close}
|
onClose={close}
|
||||||
footer={<Button onClick={close}>Close</Button>}
|
footer={<Button onClick={close}>Close</Button>}
|
||||||
>
|
>
|
||||||
<div className="workflow-runs-toolbar">
|
<ActionToolbar className="workflow-runs-toolbar">
|
||||||
<span>
|
<span>
|
||||||
<strong>Workflow instances</strong>
|
<strong>Workflow instances</strong>
|
||||||
<small>Revision-pinned runs and human handoffs</small>
|
<small>Revision-pinned runs and human handoffs</small>
|
||||||
@@ -325,7 +333,7 @@ export default function WorkflowRunsDialog({
|
|||||||
<Play size={16} /> Start
|
<Play size={16} /> Start
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</ActionToolbar>
|
||||||
{error ? (
|
{error ? (
|
||||||
<DismissibleAlert tone="danger" resetKey={error}>
|
<DismissibleAlert tone="danger" resetKey={error}>
|
||||||
{error}
|
{error}
|
||||||
@@ -389,6 +397,15 @@ export default function WorkflowRunsDialog({
|
|||||||
{selected.error}
|
{selected.error}
|
||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
) : null}
|
) : null}
|
||||||
|
{selected.view_context && !viewsRuntime ? (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="warning"
|
||||||
|
resetKey={`view-unavailable:${selected.id}:${selected.current_step_id ?? "none"}`}
|
||||||
|
>
|
||||||
|
The focused View is unavailable. Workflow position is preserved;
|
||||||
|
use the linked module action or ask an administrator to enable Views.
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
{currentStep ? (
|
{currentStep ? (
|
||||||
<section className="workflow-run-handoff">
|
<section className="workflow-run-handoff">
|
||||||
<div>
|
<div>
|
||||||
@@ -416,13 +433,22 @@ export default function WorkflowRunsDialog({
|
|||||||
&& currentStep.handoff.instructions ? (
|
&& currentStep.handoff.instructions ? (
|
||||||
<p>{currentStep.handoff.instructions}</p>
|
<p>{currentStep.handoff.instructions}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{unavailableOptionalCapabilities.length ? (
|
||||||
|
<DismissibleAlert
|
||||||
|
tone="info"
|
||||||
|
resetKey={`optional:${currentStep.id}:${unavailableOptionalCapabilities.join(",")}`}
|
||||||
|
>
|
||||||
|
Optional integration unavailable: {unavailableOptionalCapabilities.join(", ")}.
|
||||||
|
The hand-off remains valid and resumable.
|
||||||
|
</DismissibleAlert>
|
||||||
|
) : null}
|
||||||
{actionUrl ? (
|
{actionUrl ? (
|
||||||
<a href={actionUrl}>
|
<a href={actionUrl}>
|
||||||
Open linked Dataflow result <ExternalLink size={14} />
|
Open linked work <ExternalLink size={14} />
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
{allowedActions.some((action) => action !== "cancel") ? (
|
{allowedActions.some((action) => action !== "cancel") ? (
|
||||||
<div className="workflow-run-action-form">
|
<ContentGrid columns={2} gap="compact" collapseAt="narrow" className="workflow-run-action-form">
|
||||||
<FormField label="Comment">
|
<FormField label="Comment">
|
||||||
<textarea
|
<textarea
|
||||||
value={comment}
|
value={comment}
|
||||||
@@ -466,7 +492,7 @@ export default function WorkflowRunsDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ContentGrid>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+17
-418
@@ -1,14 +1,3 @@
|
|||||||
.workflow-page {
|
|
||||||
position: relative;
|
|
||||||
height: calc(100vh - 115px);
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-dialog {
|
.workflow-definition-dialog {
|
||||||
width: min(620px, calc(100vw - 32px));
|
width: min(620px, calc(100vw - 32px));
|
||||||
}
|
}
|
||||||
@@ -109,21 +98,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-definition-toggles {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-provenance {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 10px;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-provenance span,
|
.workflow-provenance span,
|
||||||
.workflow-provenance small {
|
.workflow-provenance small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -136,34 +110,8 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.workflow-definition-toggles {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-page *,
|
|
||||||
.workflow-page *::before,
|
|
||||||
.workflow-page *::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-shell {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
border: var(--border-line);
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-panel,
|
|
||||||
.workflow-workspace,
|
.workflow-workspace,
|
||||||
.workflow-editor,
|
.workflow-editor,
|
||||||
.workflow-editor-surface,
|
|
||||||
.workflow-canvas,
|
.workflow-canvas,
|
||||||
.workflow-definition-list-frame,
|
.workflow-definition-list-frame,
|
||||||
.workflow-inspector-column {
|
.workflow-inspector-column {
|
||||||
@@ -171,31 +119,6 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-definition-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border-right: var(--border-line);
|
|
||||||
background: var(--panel-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-panel-toolbar,
|
|
||||||
.workflow-workspace-toolbar,
|
|
||||||
.workflow-panel-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
background: var(--panel-header);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-panel-toolbar {
|
|
||||||
min-height: 52px;
|
|
||||||
padding: 8px 10px 8px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-toolbar-actions,
|
.workflow-toolbar-actions,
|
||||||
.workflow-command-bar,
|
.workflow-command-bar,
|
||||||
.workflow-identity-fields {
|
.workflow-identity-fields {
|
||||||
@@ -204,98 +127,12 @@
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-definition-search {
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-search input {
|
|
||||||
min-height: 34px;
|
|
||||||
padding: 7px 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list-frame {
|
.workflow-definition-list-frame {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-definition-list {
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list > button {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 56px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 8px 9px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list > button:hover,
|
|
||||||
.workflow-definition-list > button:focus-visible {
|
|
||||||
background: var(--primary-soft);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list > button.is-selected {
|
|
||||||
background: var(--primary-soft-strong);
|
|
||||||
box-shadow: inset 3px 0 0 var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list > button > span:first-child {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list strong,
|
|
||||||
.workflow-definition-list small {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list strong {
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-list small {
|
|
||||||
margin-top: 4px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-empty,
|
|
||||||
.workflow-inspector-empty {
|
|
||||||
display: grid;
|
|
||||||
min-height: 100px;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace-toolbar {
|
.workflow-workspace-toolbar {
|
||||||
min-height: 58px;
|
min-width: 0;
|
||||||
padding: 8px 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-identity-fields {
|
.workflow-identity-fields {
|
||||||
@@ -364,17 +201,12 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-palette,
|
|
||||||
.workflow-inspector,
|
.workflow-inspector,
|
||||||
.workflow-inspector-column {
|
.workflow-inspector-column {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--panel-soft);
|
background: var(--panel-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-palette {
|
|
||||||
border-right: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-inspector-column {
|
.workflow-inspector-column {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -388,11 +220,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-panel-heading {
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 8px 10px 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-panel-heading > span {
|
.workflow-panel-heading > span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -416,132 +243,6 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-palette-items {
|
|
||||||
display: grid;
|
|
||||||
grid-auto-rows: max-content;
|
|
||||||
align-content: start;
|
|
||||||
height: calc(100% - 44px);
|
|
||||||
gap: 4px;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-group + .workflow-palette-group {
|
|
||||||
margin-top: 6px;
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-group h3 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0;
|
|
||||||
padding: 3px 8px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items button {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 18px minmax(0, 1fr) 14px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
min-height: 38px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
cursor: grab;
|
|
||||||
font: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 7px 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items button:hover:not(:disabled),
|
|
||||||
.workflow-palette-items button:focus-visible:not(:disabled) {
|
|
||||||
background: var(--primary-soft);
|
|
||||||
color: var(--text-strong);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items button:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: .42;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-add {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-editor-surface {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow {
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__background {
|
|
||||||
color: var(--line-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__controls,
|
|
||||||
.workflow-canvas .react-flow__minimap {
|
|
||||||
overflow: hidden;
|
|
||||||
border: var(--border-line);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--panel);
|
|
||||||
box-shadow: var(--shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__controls-button {
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__minimap-mask {
|
|
||||||
fill: color-mix(in srgb, var(--bg) 76%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__edge-path {
|
|
||||||
stroke: var(--line-dark);
|
|
||||||
stroke-width: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas .react-flow__edge.selected .react-flow__edge-path,
|
|
||||||
.workflow-canvas .react-flow__edge:hover .react-flow__edge-path {
|
|
||||||
stroke: var(--accent);
|
|
||||||
stroke-width: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-canvas-empty {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--muted);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-node {
|
.workflow-node {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -552,7 +253,7 @@
|
|||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--line-dark);
|
border: 1px solid var(--line-dark);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-compact);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
box-shadow: var(--shadow-xs);
|
box-shadow: var(--shadow-xs);
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -606,7 +307,7 @@
|
|||||||
height: 42px;
|
height: 42px;
|
||||||
flex-basis: 42px;
|
flex-basis: 42px;
|
||||||
border: 2px solid var(--text-strong);
|
border: 2px solid var(--text-strong);
|
||||||
border-radius: 50%;
|
border-radius: var(--radius-round);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,7 +329,7 @@
|
|||||||
height: 42px;
|
height: 42px;
|
||||||
flex-basis: 42px;
|
flex-basis: 42px;
|
||||||
border: 2px solid var(--text-strong);
|
border: 2px solid var(--text-strong);
|
||||||
border-radius: 2px;
|
border-radius: var(--radius-hairline);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
@@ -671,17 +372,6 @@
|
|||||||
border-color: var(--danger-text);
|
border-color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-icon {
|
|
||||||
display: grid;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
flex: 0 0 28px;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: var(--panel-soft);
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-node-copy {
|
.workflow-node-copy {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
@@ -706,21 +396,6 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-node-handle {
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
border: 3px solid var(--panel);
|
|
||||||
background: var(--line-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-node-handle:hover,
|
|
||||||
.workflow-node-handle.connectingto,
|
|
||||||
.workflow-node-handle.valid {
|
|
||||||
width: 17px;
|
|
||||||
height: 17px;
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-edge-bpmn-messageFlow .react-flow__edge-path,
|
.workflow-edge-bpmn-messageFlow .react-flow__edge-path,
|
||||||
.workflow-edge-bpmn-association .react-flow__edge-path,
|
.workflow-edge-bpmn-association .react-flow__edge-path,
|
||||||
.workflow-edge-bpmn-conversationLink .react-flow__edge-path {
|
.workflow-edge-bpmn-conversationLink .react-flow__edge-path {
|
||||||
@@ -776,6 +451,18 @@
|
|||||||
color: var(--danger-text);
|
color: var(--danger-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow-inspector-actions .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.workflow-diagnostics {
|
.workflow-diagnostics {
|
||||||
display: flex;
|
display: flex;
|
||||||
max-height: 38%;
|
max-height: 38%;
|
||||||
@@ -821,39 +508,6 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-workspace-empty {
|
|
||||||
display: grid;
|
|
||||||
height: 100%;
|
|
||||||
place-content: center;
|
|
||||||
justify-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace-empty strong {
|
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace-empty .btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-working-indicator {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 20;
|
|
||||||
right: 12px;
|
|
||||||
bottom: 10px;
|
|
||||||
border: var(--border-line-dark);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--panel);
|
|
||||||
box-shadow: var(--shadow-popover);
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 7px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-runs-dialog {
|
.workflow-runs-dialog {
|
||||||
width: min(1120px, calc(100vw - 32px));
|
width: min(1120px, calc(100vw - 32px));
|
||||||
height: min(760px, calc(100vh - 32px));
|
height: min(760px, calc(100vh - 32px));
|
||||||
@@ -1041,12 +695,6 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-run-action-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-run-action-form textarea {
|
.workflow-run-action-form textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -1106,11 +754,7 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1280px) {
|
||||||
.workflow-shell {
|
|
||||||
grid-template-columns: 240px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-workspace-toolbar {
|
.workflow-workspace-toolbar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1127,10 +771,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.workflow-shell {
|
|
||||||
grid-template-columns: 210px minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-editor {
|
.workflow-editor {
|
||||||
grid-template-columns: 140px minmax(0, 1fr);
|
grid-template-columns: 140px minmax(0, 1fr);
|
||||||
grid-template-rows: minmax(0, 1fr) minmax(180px, 34%);
|
grid-template-rows: minmax(0, 1fr) minmax(180px, 34%);
|
||||||
@@ -1149,16 +789,6 @@
|
|||||||
height: calc(100vh - 94px);
|
height: calc(100vh - 94px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: minmax(150px, 24%) minmax(0, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-definition-panel {
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-identity-fields,
|
.workflow-identity-fields,
|
||||||
.workflow-command-bar {
|
.workflow-command-bar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1175,33 +805,6 @@
|
|||||||
grid-template-rows: auto minmax(0, 1fr) minmax(180px, 32%);
|
grid-template-rows: auto minmax(0, 1fr) minmax(180px, 32%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-palette {
|
|
||||||
border-right: 0;
|
|
||||||
border-bottom: var(--border-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette .workflow-panel-heading {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-group {
|
|
||||||
display: flex;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-group h3 {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-palette-items button {
|
|
||||||
min-width: 128px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-runs-layout {
|
.workflow-runs-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: minmax(120px, 28%) minmax(0, 1fr);
|
grid-template-rows: minmax(120px, 28%) minmax(0, 1fr);
|
||||||
@@ -1212,10 +815,6 @@
|
|||||||
border-bottom: var(--border-line);
|
border-bottom: var(--border-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-run-action-form {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workflow-run-history > div > span,
|
.workflow-run-history > div > span,
|
||||||
.workflow-run-events > div > span {
|
.workflow-run-events > div > span {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user