feat: expose workflow semantic documentation subjects

This commit is contained in:
2026-08-21 15:53:44 +02:00
parent f49ad8eff4
commit 0fd744a2db
10 changed files with 967 additions and 17 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-workflow" name = "govoplan-workflow"
version = "0.1.18" version = "0.1.19"
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"
+96 -3
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute, FrontendRoute,
ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
ModuleInterfaceRequirement, ModuleInterfaceRequirement,
ModuleManifest, ModuleManifest,
@@ -13,6 +15,13 @@ from govoplan_core.core.modules import (
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,
@@ -25,7 +34,14 @@ from govoplan_workflow_engine.backend.manifest import (
MODULE_ID = "workflow" MODULE_ID = "workflow"
MODULE_NAME = "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( manifest = ModuleManifest(
@@ -33,9 +49,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",
@@ -104,6 +139,56 @@ manifest = ModuleManifest(
), ),
), ),
documentation=( 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( DocumentationTopic(
id="workflow.editor", id="workflow.editor",
title="Workflow editor and inspection workspace", title="Workflow editor and inspection workspace",
@@ -138,8 +223,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,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",
]
+27 -1
View File
@@ -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()
+293
View File
@@ -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()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/workflow-webui", "name": "@govoplan/workflow-webui",
"version": "0.1.18", "version": "0.1.19",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -1,8 +1,9 @@
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, ActionToolbar,
Button, Button,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FormField, FormField,
ReferenceMultiSelect, ReferenceMultiSelect,
@@ -21,6 +22,8 @@ export default function WorkflowInspector({
edge, edge,
nodeLibrary, nodeLibrary,
readOnly, readOnly,
semanticDefinitionId,
semanticStepAvailable,
onChange, onChange,
onDelete, onDelete,
onEdgeChange, onEdgeChange,
@@ -30,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;
@@ -207,16 +212,35 @@ export default function WorkflowInspector({
<strong>Inspector</strong> <strong>Inspector</strong>
<small>{definition?.label ?? node.type}</small> <small>{definition?.label ?? node.type}</small>
</span> </span>
<Button <span className="workflow-inspector-actions">
variant="ghost" {semanticDefinitionId && semanticStepAvailable ? <>
className="workflow-inspector-delete" <DocumentationHelpLink
onClick={() => onDelete(node.id)} reference={{
disabled={readOnly} contextId: semanticHelpContext(semanticDefinitionId, node.id),
aria-label="Delete node" documentationType: "user"
title="Delete node" }}
> label="Open step meaning"
<Trash2 size={16} /> />
</Button> <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
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onDelete(node.id)}
disabled={readOnly}
aria-label="Delete node"
title="Delete node"
>
<Trash2 size={16} />
</Button>
</span>
</ActionToolbar> </ActionToolbar>
<div className="workflow-inspector-fields"> <div className="workflow-inspector-fields">
{error ? ( {error ? (
@@ -332,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}`;
}
@@ -104,6 +104,7 @@ const iconByName: Record<string, LucideIcon> = {
}; };
export default function WorkflowNode({ export default function WorkflowNode({
id,
data, data,
selected, selected,
isConnectable isConnectable
@@ -114,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}`,
@@ -8,6 +8,7 @@ import {
} from "react"; } from "react";
import { import {
Archive, Archive,
BookOpen,
CheckCircle2, CheckCircle2,
CopyPlus, CopyPlus,
Download, Download,
@@ -708,6 +709,23 @@ export default function WorkflowPage({
aria-label="Workflow description" aria-label="Workflow description"
/> />
</span>} </span>}
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"> primaryActions={<span className="workflow-command-bar">
{draft.id ? ( {draft.id ? (
<select <select
@@ -927,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));
@@ -1053,6 +1080,32 @@ export default function WorkflowPage({
); );
} }
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,
+12
View File
@@ -451,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%;