feat: expose workflow semantic documentation subjects
This commit is contained in:
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/workflow-webui",
|
||||
"version": "0.1.18",
|
||||
"version": "0.1.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -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({
|
||||
<strong>Inspector</strong>
|
||||
<small>{definition?.label ?? node.type}</small>
|
||||
</span>
|
||||
<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 className="workflow-inspector-actions">
|
||||
{semanticDefinitionId && semanticStepAvailable ? <>
|
||||
<DocumentationHelpLink
|
||||
reference={{
|
||||
contextId: semanticHelpContext(semanticDefinitionId, node.id),
|
||||
documentationType: "user"
|
||||
}}
|
||||
label="Open step meaning"
|
||||
/>
|
||||
<a
|
||||
className="btn btn-secondary"
|
||||
href={semanticAuthoringHref(semanticDefinitionId, node.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<BookOpen size={15} aria-hidden="true" /> Document meaning
|
||||
</a>
|
||||
</> : null}
|
||||
<Button
|
||||
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>
|
||||
<div className="workflow-inspector-fields">
|
||||
{error ? (
|
||||
@@ -332,3 +356,23 @@ function stringList(value: unknown): string[] {
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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({
|
||||
id,
|
||||
data,
|
||||
selected,
|
||||
isConnectable
|
||||
@@ -114,6 +115,7 @@ export default function WorkflowNode({
|
||||
const outputPorts = data.definition.output_ports;
|
||||
return (
|
||||
<div
|
||||
id={`workflow-node-${id}`}
|
||||
className={[
|
||||
"workflow-node",
|
||||
`workflow-node-${data.definition.category}`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
Archive,
|
||||
BookOpen,
|
||||
CheckCircle2,
|
||||
CopyPlus,
|
||||
Download,
|
||||
@@ -708,6 +709,23 @@ export default function WorkflowPage({
|
||||
aria-label="Workflow description"
|
||||
/>
|
||||
</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">
|
||||
{draft.id ? (
|
||||
<select
|
||||
@@ -927,6 +945,15 @@ export default function WorkflowPage({
|
||||
edge={selectedEdge}
|
||||
nodeLibrary={nodeLibrary}
|
||||
readOnly={graphReadOnly}
|
||||
semanticDefinitionId={draft.id || null}
|
||||
semanticStepAvailable={Boolean(
|
||||
draft.id
|
||||
&& !historicalRevision
|
||||
&& selectedNode
|
||||
&& savedDraft?.graph.nodes.some(
|
||||
(node) => node.id === selectedNode.id
|
||||
)
|
||||
)}
|
||||
onChange={(node) => {
|
||||
if (!draft || graphReadOnly) return;
|
||||
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({
|
||||
open,
|
||||
settings,
|
||||
|
||||
@@ -451,6 +451,18 @@
|
||||
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 {
|
||||
display: flex;
|
||||
max-height: 38%;
|
||||
|
||||
Reference in New Issue
Block a user