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
+27 -1
View File
@@ -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()
+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()