From 0fd744a2dbe9e9bf6fab8686d2b366fea8c1891e Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 21 Aug 2026 15:53:44 +0200 Subject: [PATCH] feat: expose workflow semantic documentation subjects --- pyproject.toml | 2 +- src/govoplan_workflow/backend/manifest.py | 99 +++- .../backend/semantic_subjects.py | 427 ++++++++++++++++++ tests/test_manifest.py | 28 +- tests/test_semantic_subjects.py | 293 ++++++++++++ webui/package.json | 2 +- .../features/workflow/WorkflowInspector.tsx | 66 ++- webui/src/features/workflow/WorkflowNode.tsx | 2 + webui/src/features/workflow/WorkflowPage.tsx | 53 +++ webui/src/styles/workflow.css | 12 + 10 files changed, 967 insertions(+), 17 deletions(-) create mode 100644 src/govoplan_workflow/backend/semantic_subjects.py create mode 100644 tests/test_semantic_subjects.py 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 ? <> + + + + : null} + +
{error ? ( @@ -332,3 +356,23 @@ function stringList(value: unknown): string[] { function isRecord(value: unknown): value is Record { 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}`; +} diff --git a/webui/src/features/workflow/WorkflowNode.tsx b/webui/src/features/workflow/WorkflowNode.tsx index 5f07e63..7705d0e 100644 --- a/webui/src/features/workflow/WorkflowNode.tsx +++ b/webui/src/features/workflow/WorkflowNode.tsx @@ -104,6 +104,7 @@ const iconByName: Record = { }; export default function WorkflowNode({ + id, data, selected, isConnectable @@ -114,6 +115,7 @@ export default function WorkflowNode({ const outputPorts = data.definition.output_ports; return (
} + helpAction={draft.id ? <> + + + + : undefined} primaryActions={ {draft.id ? (