Add shared automation and WebUI editing primitives

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent f0898fcdee
commit 5b55f59a92
42 changed files with 3788 additions and 554 deletions
+144
View File
@@ -3,11 +3,20 @@ from __future__ import annotations
import unittest
from govoplan_core.core.automation import (
ActionDefinition,
ActionEffectProvider,
ActionExecutionRequest,
ActionExecutionResult,
ActionPreview,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
AutomationInvocation,
AutomationPrincipalProvider,
AutomationPrincipalRequest,
AutomationPrincipalResolution,
EffectDefinition,
EffectPreview,
ObservedEffect,
action_effect_provider,
automation_principal_provider,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
@@ -24,6 +33,66 @@ class _Provider:
)
class _ActionProvider:
action = ActionDefinition(
action_key="postbox.message.deliver",
owner_module="postbox",
description="Deliver one governed Postbox message.",
input_schema_ref="schema:postbox.message.deliver@1",
required_scopes=("postbox:message:write",),
required_capabilities=("postbox.delivery",),
risk_level="high",
reversibility="compensatable",
expected_effect_keys=("postbox.message.created",),
audit_event_types=("postbox.message.delivered",),
)
effect = EffectDefinition(
effect_key="postbox.message.created",
owner_module="postbox",
operation="created",
description="A durable Postbox message was created.",
resource_types=("postbox_message",),
)
def action_definitions(self):
return (self.action,)
def effect_definitions(self):
return (self.effect,)
def preview_action(self, session, principal, *, request):
del session, principal
return ActionPreview(
action_key=request.action_key,
allowed=True,
summary="One Postbox message will be created.",
risk_level=self.action.risk_level,
reversibility=self.action.reversibility,
effects=(
EffectPreview(
effect_key=self.effect.effect_key,
summary="Create message.",
),
),
preview_ref="preview:1",
)
def execute_action(self, session, principal, *, request):
del session, principal
return ActionExecutionResult(
state="completed",
output={"message_ref": "postbox-message:1"},
observed_effects=(
ObservedEffect(
effect_key=self.effect.effect_key,
operation="created",
resource_ref="postbox-message:1",
),
),
audit_event_refs=("audit-event:1",),
)
class AutomationContractTests(unittest.TestCase):
def test_principal_request_has_explicit_subject_contracts(self) -> None:
service = AutomationPrincipalRequest.service_account(
@@ -91,6 +160,81 @@ class AutomationContractTests(unittest.TestCase):
self.assertTrue(result.allowed)
self.assertEqual(("dataflow:pipeline:run",), result.granted_scopes)
def test_action_effect_provider_is_previewable_and_idempotent_by_contract(
self,
) -> None:
provider = _ActionProvider()
self.assertIsInstance(provider, ActionEffectProvider)
request = ActionExecutionRequest(
tenant_id="tenant-1",
action_key=provider.action.action_key,
input={"postbox_ref": "postbox:1"},
idempotency_key="workflow:instance-1:step-2:attempt-1",
invocation=AutomationInvocation(
kind="workflow",
trigger_ref="workflow-instance:1",
),
)
preview = provider.preview_action(object(), object(), request=request)
result = provider.execute_action(object(), object(), request=request)
self.assertTrue(preview.allowed)
self.assertEqual("compensatable", preview.reversibility)
self.assertEqual("completed", result.state)
self.assertEqual(
"postbox-message:1",
result.observed_effects[0].resource_ref,
)
def test_action_effect_provider_is_resolved_by_capability_name(self) -> None:
provider = _ActionProvider()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="action_contract_test",
name="Action contract test",
version="test",
capability_factories={
"postbox.actions": lambda context: provider,
"invalid.actions": lambda context: object(),
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(
provider,
action_effect_provider(registry, "postbox.actions"),
)
self.assertIsNone(
action_effect_provider(registry, "invalid.actions")
)
self.assertIsNone(
action_effect_provider(registry, "missing.actions")
)
def test_action_contract_rejects_unversioned_or_incomplete_definitions(
self,
) -> None:
with self.assertRaisesRegex(ValueError, "input schema"):
ActionDefinition(
action_key="invalid",
owner_module="test",
description="Invalid action",
input_schema_ref="",
)
with self.assertRaisesRegex(ValueError, "contract version"):
EffectDefinition(
effect_key="test.effect",
owner_module="test",
operation="changed",
description="Test effect",
contract_version="2",
)
if __name__ == "__main__":
unittest.main()