diff --git a/pyproject.toml b/pyproject.toml
index 2e46ed7..e674f29 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-workflow"
-version = "0.1.18"
+version = "0.1.19"
description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine."
readme = "README.md"
requires-python = ">=3.12"
diff --git a/src/govoplan_workflow/backend/manifest.py b/src/govoplan_workflow/backend/manifest.py
index 108136f..4915f1f 100644
--- a/src/govoplan_workflow/backend/manifest.py
+++ b/src/govoplan_workflow/backend/manifest.py
@@ -1,10 +1,12 @@
from __future__ import annotations
from govoplan_core.core.modules import (
+ CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
+ ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
@@ -13,6 +15,13 @@ from govoplan_core.core.modules import (
ViewSurface,
)
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 (
ADMIN_SCOPE,
DEFINITION_READ_SCOPE,
@@ -25,7 +34,14 @@ from govoplan_workflow_engine.backend.manifest import (
MODULE_ID = "workflow"
MODULE_NAME = "Workflow"
-MODULE_VERSION = "0.1.18"
+MODULE_VERSION = "0.1.19"
+SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID)
+
+
+def _semantic_subjects(
+ context: ModuleContext,
+) -> WorkflowSemanticDocumentationSubjectProvider:
+ return WorkflowSemanticDocumentationSubjectProvider(context.registry)
manifest = ModuleManifest(
@@ -33,9 +49,28 @@ manifest = ModuleManifest(
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("workflow_engine",),
+ optional_dependencies=("docs",),
provides_interfaces=(
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=(
ModuleInterfaceRequirement(
name="workflow.definition_graph",
@@ -104,6 +139,56 @@ manifest = ModuleManifest(
),
),
documentation=(
+ 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(
id="workflow.editor",
title="Workflow editor and inspection workspace",
@@ -138,8 +223,16 @@ manifest = ModuleManifest(
maturity="vertical_slice",
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
test_ref="tests/test_manifest.py",
- known_limits=("The editor intentionally cannot execute definitions without Workflow Engine and target-tested adapters.",),
- owned_concepts=("workflow editing surface", "workflow revision inspection", "workflow activation controls"),
+ known_limits=(
+ "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"),
security_docs=("docs/CONCEPT.md",),
),
diff --git a/src/govoplan_workflow/backend/semantic_subjects.py b/src/govoplan_workflow/backend/semantic_subjects.py
new file mode 100644
index 0000000..1f55412
--- /dev/null
+++ b/src/govoplan_workflow/backend/semantic_subjects.py
@@ -0,0 +1,427 @@
+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_engine.backend.db.models import (
+ WorkflowDefinition,
+ WorkflowDefinitionRevision,
+)
+from govoplan_workflow_engine.backend.governance import definition_decision
+from govoplan_workflow_engine.backend.manifest import ADMIN_SCOPE, DEFINITION_READ_SCOPE
+from govoplan_workflow_engine.backend.service import (
+ get_definition_revision,
+ list_definition_revisions,
+ list_definitions,
+)
+
+
+SUBJECT_KIND = "workflow_definition"
+_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",
+]
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index dca44bf..1b0b8b6 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+from pathlib import Path
import unittest
from govoplan_workflow.backend.manifest import get_manifest
@@ -22,12 +23,37 @@ class WorkflowManifestTests(unittest.TestCase):
self.assertEqual((), manifest.permissions)
self.assertIsNone(manifest.route_factory)
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(
"@govoplan/workflow-webui",
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__":
unittest.main()
diff --git a/tests/test_semantic_subjects.py b/tests/test_semantic_subjects.py
new file mode 100644
index 0000000..ca4b17d
--- /dev/null
+++ b/tests/test_semantic_subjects.py
@@ -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()
diff --git a/webui/package.json b/webui/package.json
index f223809..b6c8c91 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -1,6 +1,6 @@
{
"name": "@govoplan/workflow-webui",
- "version": "0.1.18",
+ "version": "0.1.19",
"private": true,
"type": "module",
"main": "src/index.ts",
diff --git a/webui/src/features/workflow/WorkflowInspector.tsx b/webui/src/features/workflow/WorkflowInspector.tsx
index 8254a9e..ebb5d1c 100644
--- a/webui/src/features/workflow/WorkflowInspector.tsx
+++ b/webui/src/features/workflow/WorkflowInspector.tsx
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react";
-import { Trash2 } from "lucide-react";
+import { BookOpen, Trash2 } from "lucide-react";
import {
ActionToolbar,
Button,
+ DocumentationHelpLink,
DismissibleAlert,
FormField,
ReferenceMultiSelect,
@@ -21,6 +22,8 @@ export default function WorkflowInspector({
edge,
nodeLibrary,
readOnly,
+ semanticDefinitionId,
+ semanticStepAvailable,
onChange,
onDelete,
onEdgeChange,
@@ -30,6 +33,8 @@ export default function WorkflowInspector({
edge: WorkflowGraphEdge | null;
nodeLibrary: WorkflowNodeType[];
readOnly: boolean;
+ semanticDefinitionId: string | null;
+ semanticStepAvailable: boolean;
onChange: (node: WorkflowGraphNode) => void;
onDelete: (nodeId: string) => void;
onEdgeChange: (edge: WorkflowGraphEdge) => void;
@@ -207,16 +212,35 @@ export default function WorkflowInspector({
Inspector
{definition?.label ?? node.type}
-
+
+ {semanticDefinitionId && semanticStepAvailable ? <>
+