Add shared work-item provider contracts
This commit is contained in:
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
|||||||
SearchProviderRegistration,
|
SearchProviderRegistration,
|
||||||
SearchSourceProviderRegistration,
|
SearchSourceProviderRegistration,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.tasks import WorkItemProviderRegistration
|
||||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +35,9 @@ MigrationTaskPhase = Literal[
|
|||||||
"post_migration_backfill",
|
"post_migration_backfill",
|
||||||
"post_migration_verify",
|
"post_migration_verify",
|
||||||
]
|
]
|
||||||
MigrationTaskSafety = Literal["automatic", "requires_review", "forward_only", "destructive"]
|
MigrationTaskSafety = Literal[
|
||||||
|
"automatic", "requires_review", "forward_only", "destructive"
|
||||||
|
]
|
||||||
MigrationTaskStatus = Literal["ok", "warning", "blocked", "skipped"]
|
MigrationTaskStatus = Literal["ok", "warning", "blocked", "skipped"]
|
||||||
|
|
||||||
|
|
||||||
@@ -75,8 +78,6 @@ class NavItem:
|
|||||||
surface_id: str | None = None
|
surface_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class FrontendRoute:
|
class FrontendRoute:
|
||||||
path: str
|
path: str
|
||||||
@@ -147,7 +148,9 @@ class ModuleMigrationTaskResult:
|
|||||||
details: Mapping[str, Any] = field(default_factory=dict)
|
details: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
ModuleMigrationTaskExecutor = Callable[[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None]
|
ModuleMigrationTaskExecutor = Callable[
|
||||||
|
[ModuleMigrationTaskContext], ModuleMigrationTaskResult | None
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -204,7 +207,9 @@ class ModuleUninstallGuardResult:
|
|||||||
message: str
|
message: str
|
||||||
|
|
||||||
|
|
||||||
UninstallGuardProvider = Callable[[object | None, str], Iterable[ModuleUninstallGuardResult]]
|
UninstallGuardProvider = Callable[
|
||||||
|
[object | None, str], Iterable[ModuleUninstallGuardResult]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -224,7 +229,9 @@ class ModuleContext:
|
|||||||
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
|
DocumentationLayer = Literal["always", "configured", "available", "evidence"]
|
||||||
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
|
DocumentationLinkKind = Literal["runtime", "api", "repository", "wiki", "public"]
|
||||||
DocumentationType = Literal["admin", "user"]
|
DocumentationType = Literal["admin", "user"]
|
||||||
DocumentationConfigurationState = Literal["enabled", "disabled", "inherited", "unavailable"]
|
DocumentationConfigurationState = Literal[
|
||||||
|
"enabled", "disabled", "inherited", "unavailable"
|
||||||
|
]
|
||||||
DocumentationSourceKind = Literal[
|
DocumentationSourceKind = Literal[
|
||||||
"manifest",
|
"manifest",
|
||||||
"route",
|
"route",
|
||||||
@@ -289,16 +296,23 @@ def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
raw_kind = topic.metadata.get("kind")
|
raw_kind = topic.metadata.get("kind")
|
||||||
kind = raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
kind = (
|
||||||
|
raw_kind.strip().lower().replace("_", "-") if isinstance(raw_kind, str) else ""
|
||||||
|
)
|
||||||
if kind != "workflow" or "user" not in topic.documentation_types:
|
if kind != "workflow" or "user" not in topic.documentation_types:
|
||||||
return ()
|
return ()
|
||||||
if not topic.conditions:
|
if not topic.conditions:
|
||||||
return ("user workflow topics must declare at least one scope-conditioned alternative",)
|
return (
|
||||||
|
"user workflow topics must declare at least one scope-conditioned alternative",
|
||||||
|
)
|
||||||
|
|
||||||
unscoped_alternatives = tuple(
|
unscoped_alternatives = tuple(
|
||||||
index
|
index
|
||||||
for index, condition in enumerate(topic.conditions, start=1)
|
for index, condition in enumerate(topic.conditions, start=1)
|
||||||
if not any(scope.strip() for scope in (*condition.required_scopes, *condition.any_scopes))
|
if not any(
|
||||||
|
scope.strip()
|
||||||
|
for scope in (*condition.required_scopes, *condition.any_scopes)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if not unscoped_alternatives:
|
if not unscoped_alternatives:
|
||||||
return ()
|
return ()
|
||||||
@@ -370,14 +384,11 @@ class CapabilityDocumentation:
|
|||||||
class ResourceAclProvider(Protocol):
|
class ResourceAclProvider(Protocol):
|
||||||
resource_type: str
|
resource_type: str
|
||||||
|
|
||||||
def can_read(self, principal: object, resource_id: str) -> bool:
|
def can_read(self, principal: object, resource_id: str) -> bool: ...
|
||||||
...
|
|
||||||
|
|
||||||
def can_write(self, principal: object, resource_id: str) -> bool:
|
def can_write(self, principal: object, resource_id: str) -> bool: ...
|
||||||
...
|
|
||||||
|
|
||||||
def explain(self, principal: object, resource_id: str) -> AccessDecision:
|
def explain(self, principal: object, resource_id: str) -> AccessDecision: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
TenantSummaryProvider = Callable[[object, str], Mapping[str, int]]
|
||||||
@@ -436,12 +447,17 @@ class ModuleManifest:
|
|||||||
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
|
ownership_providers: tuple[OwnershipProviderRegistration, ...] = ()
|
||||||
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
tenant_summary_providers: tuple[TenantSummaryProvider, ...] = ()
|
||||||
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
tenant_summary_batch_providers: tuple[TenantSummaryBatchProvider, ...] = ()
|
||||||
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
|
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
|
||||||
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
|
||||||
capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict)
|
capability_documentation: Mapping[str, CapabilityDocumentation] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
search_providers: tuple["SearchProviderRegistration", ...] = ()
|
||||||
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
search_sources: tuple["SearchSourceProviderRegistration", ...] = ()
|
||||||
|
work_item_providers: tuple["WorkItemProviderRegistration", ...] = ()
|
||||||
operational_check_providers: tuple[
|
operational_check_providers: tuple[
|
||||||
"OperationalCheckProviderRegistration",
|
"OperationalCheckProviderRegistration",
|
||||||
...,
|
...,
|
||||||
|
|||||||
+351
-121
@@ -48,13 +48,21 @@ from govoplan_core.core.provider_governance import (
|
|||||||
ModuleArchitectureDeclaration,
|
ModuleArchitectureDeclaration,
|
||||||
module_architecture_issues,
|
module_architecture_issues,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
from govoplan_core.core.versioning import (
|
||||||
|
format_version_range,
|
||||||
|
version_range_is_valid,
|
||||||
|
version_satisfies_range,
|
||||||
|
)
|
||||||
from govoplan_core.core.search import (
|
from govoplan_core.core.search import (
|
||||||
RegisteredSearchProvider,
|
RegisteredSearchProvider,
|
||||||
RegisteredSearchSourceProvider,
|
RegisteredSearchSourceProvider,
|
||||||
SearchProvider,
|
SearchProvider,
|
||||||
SearchSourceProvider,
|
SearchSourceProvider,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.tasks import (
|
||||||
|
RegisteredWorkItemProvider,
|
||||||
|
WorkItemProvider,
|
||||||
|
)
|
||||||
from govoplan_core.core.views import (
|
from govoplan_core.core.views import (
|
||||||
ViewSurface,
|
ViewSurface,
|
||||||
module_view_surface_id,
|
module_view_surface_id,
|
||||||
@@ -69,7 +77,9 @@ from govoplan_core.core.workflows import (
|
|||||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||||
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
||||||
_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$")
|
_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$")
|
||||||
_WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$")
|
_WILDCARD_RE = re.compile(
|
||||||
|
r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$"
|
||||||
|
)
|
||||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +100,9 @@ class PlatformRegistry:
|
|||||||
self._manifests: dict[str, ModuleManifest] = {}
|
self._manifests: dict[str, ModuleManifest] = {}
|
||||||
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
self._tenant_summary_providers: dict[str, TenantSummaryProvider] = {}
|
||||||
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
|
self._tenant_summary_batch_providers: dict[str, TenantSummaryBatchProvider] = {}
|
||||||
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = defaultdict(list)
|
self._delete_veto_providers: dict[str, list[DeleteVetoProviderRegistration]] = (
|
||||||
|
defaultdict(list)
|
||||||
|
)
|
||||||
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
|
self._ownership_providers: dict[str, OwnershipProviderRegistration] = {}
|
||||||
self._capability_factories: dict[str, CapabilityFactory] = {}
|
self._capability_factories: dict[str, CapabilityFactory] = {}
|
||||||
self._capability_factory_owners: dict[str, str] = {}
|
self._capability_factory_owners: dict[str, str] = {}
|
||||||
@@ -99,10 +111,10 @@ class PlatformRegistry:
|
|||||||
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(self)
|
self._tenant_entitlement_resolver = TenantModuleEntitlementResolver(self)
|
||||||
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
self._search_provider_registrations: list[RegisteredSearchProvider] = []
|
||||||
self._search_providers: dict[str, SearchProvider] = {}
|
self._search_providers: dict[str, SearchProvider] = {}
|
||||||
self._search_source_registrations: list[
|
self._search_source_registrations: list[RegisteredSearchSourceProvider] = []
|
||||||
RegisteredSearchSourceProvider
|
|
||||||
] = []
|
|
||||||
self._search_sources: dict[str, SearchSourceProvider] = {}
|
self._search_sources: dict[str, SearchSourceProvider] = {}
|
||||||
|
self._work_item_provider_registrations: list[RegisteredWorkItemProvider] = []
|
||||||
|
self._work_item_providers: dict[str, WorkItemProvider] = {}
|
||||||
|
|
||||||
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
def register(self, manifest: ModuleManifest) -> ModuleManifest:
|
||||||
if manifest.id in self._manifests:
|
if manifest.id in self._manifests:
|
||||||
@@ -133,6 +145,13 @@ class PlatformRegistry:
|
|||||||
registration=registration,
|
registration=registration,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
for registration in manifest.work_item_providers:
|
||||||
|
self._work_item_provider_registrations.append(
|
||||||
|
RegisteredWorkItemProvider(
|
||||||
|
module_id=manifest.id,
|
||||||
|
registration=registration,
|
||||||
|
)
|
||||||
|
)
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
|
||||||
@@ -148,24 +167,29 @@ class PlatformRegistry:
|
|||||||
self._tenant_summary_batch_providers = dict(
|
self._tenant_summary_batch_providers = dict(
|
||||||
replacement._tenant_summary_batch_providers
|
replacement._tenant_summary_batch_providers
|
||||||
)
|
)
|
||||||
self._delete_veto_providers = defaultdict(list, {
|
self._delete_veto_providers = defaultdict(
|
||||||
resource_type: list(providers)
|
list,
|
||||||
for resource_type, providers in replacement._delete_veto_providers.items()
|
{
|
||||||
})
|
resource_type: list(providers)
|
||||||
|
for resource_type, providers in replacement._delete_veto_providers.items()
|
||||||
|
},
|
||||||
|
)
|
||||||
self._ownership_providers = dict(replacement._ownership_providers)
|
self._ownership_providers = dict(replacement._ownership_providers)
|
||||||
self._capability_factories = dict(replacement._capability_factories)
|
self._capability_factories = dict(replacement._capability_factories)
|
||||||
self._capability_factory_owners = dict(
|
self._capability_factory_owners = dict(replacement._capability_factory_owners)
|
||||||
replacement._capability_factory_owners
|
|
||||||
)
|
|
||||||
self._search_provider_registrations = list(
|
self._search_provider_registrations = list(
|
||||||
replacement._search_provider_registrations
|
replacement._search_provider_registrations
|
||||||
)
|
)
|
||||||
self._search_source_registrations = list(
|
self._search_source_registrations = list(
|
||||||
replacement._search_source_registrations
|
replacement._search_source_registrations
|
||||||
)
|
)
|
||||||
|
self._work_item_provider_registrations = list(
|
||||||
|
replacement._work_item_provider_registrations
|
||||||
|
)
|
||||||
self._capabilities.clear()
|
self._capabilities.clear()
|
||||||
self._search_providers.clear()
|
self._search_providers.clear()
|
||||||
self._search_sources.clear()
|
self._search_sources.clear()
|
||||||
|
self._work_item_providers.clear()
|
||||||
self._tenant_entitlement_resolver.invalidate()
|
self._tenant_entitlement_resolver.invalidate()
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
@@ -183,7 +207,10 @@ class PlatformRegistry:
|
|||||||
|
|
||||||
def integration_enabled(self, module_id: str, dependency_id: str) -> bool:
|
def integration_enabled(self, module_id: str, dependency_id: str) -> bool:
|
||||||
manifest = self.require_module(module_id)
|
manifest = self.require_module(module_id)
|
||||||
return dependency_id in manifest.dependencies or (dependency_id in manifest.optional_dependencies and self.has_module(dependency_id))
|
return dependency_id in manifest.dependencies or (
|
||||||
|
dependency_id in manifest.optional_dependencies
|
||||||
|
and self.has_module(dependency_id)
|
||||||
|
)
|
||||||
|
|
||||||
def manifests(self) -> tuple[ModuleManifest, ...]:
|
def manifests(self) -> tuple[ModuleManifest, ...]:
|
||||||
return tuple(self._topologically_sorted())
|
return tuple(self._topologically_sorted())
|
||||||
@@ -216,13 +243,26 @@ class PlatformRegistry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def permissions(self) -> tuple[PermissionDefinition, ...]:
|
def permissions(self) -> tuple[PermissionDefinition, ...]:
|
||||||
return tuple(permission for manifest in self.manifests() for permission in manifest.permissions)
|
return tuple(
|
||||||
|
permission
|
||||||
|
for manifest in self.manifests()
|
||||||
|
for permission in manifest.permissions
|
||||||
|
)
|
||||||
|
|
||||||
def role_templates(self) -> tuple[RoleTemplate, ...]:
|
def role_templates(self) -> tuple[RoleTemplate, ...]:
|
||||||
return tuple(template for manifest in self.manifests() for template in manifest.role_templates)
|
return tuple(
|
||||||
|
template
|
||||||
|
for manifest in self.manifests()
|
||||||
|
for template in manifest.role_templates
|
||||||
|
)
|
||||||
|
|
||||||
def nav_items(self) -> tuple[NavItem, ...]:
|
def nav_items(self) -> tuple[NavItem, ...]:
|
||||||
return tuple(sorted((item for manifest in self.manifests() for item in manifest.nav_items), key=lambda item: item.order))
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
(item for manifest in self.manifests() for item in manifest.nav_items),
|
||||||
|
key=lambda item: item.order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def view_surfaces(self) -> tuple[ViewSurface, ...]:
|
def view_surfaces(self) -> tuple[ViewSurface, ...]:
|
||||||
return tuple(
|
return tuple(
|
||||||
@@ -232,7 +272,11 @@ class PlatformRegistry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
def resource_acl_providers(self) -> tuple[ResourceAclProvider, ...]:
|
||||||
return tuple(provider for manifest in self.manifests() for provider in manifest.resource_acl_providers)
|
return tuple(
|
||||||
|
provider
|
||||||
|
for manifest in self.manifests()
|
||||||
|
for provider in manifest.resource_acl_providers
|
||||||
|
)
|
||||||
|
|
||||||
def register_ownership_provider(
|
def register_ownership_provider(
|
||||||
self,
|
self,
|
||||||
@@ -295,7 +339,9 @@ class PlatformRegistry:
|
|||||||
self._search_providers.clear()
|
self._search_providers.clear()
|
||||||
self._search_sources.clear()
|
self._search_sources.clear()
|
||||||
|
|
||||||
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
|
def register_capability_factory(
|
||||||
|
self, module_id: str, name: str, factory: CapabilityFactory
|
||||||
|
) -> None:
|
||||||
if name in self._capability_factories:
|
if name in self._capability_factories:
|
||||||
raise RegistryError(f"Duplicate capability: {name}")
|
raise RegistryError(f"Duplicate capability: {name}")
|
||||||
self._capability_factories[name] = factory
|
self._capability_factories[name] = factory
|
||||||
@@ -446,9 +492,7 @@ class PlatformRegistry:
|
|||||||
]:
|
]:
|
||||||
if self._capability_context is None:
|
if self._capability_context is None:
|
||||||
if self._search_source_registrations:
|
if self._search_source_registrations:
|
||||||
raise RegistryError(
|
raise RegistryError("Search source context is not configured.")
|
||||||
"Search source context is not configured."
|
|
||||||
)
|
|
||||||
return ()
|
return ()
|
||||||
providers: list[
|
providers: list[
|
||||||
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
|
tuple[RegisteredSearchSourceProvider, SearchSourceProvider]
|
||||||
@@ -463,14 +507,51 @@ class PlatformRegistry:
|
|||||||
key = f"{registered.module_id}:{registered.registration.id}"
|
key = f"{registered.module_id}:{registered.registration.id}"
|
||||||
provider = self._search_sources.get(key)
|
provider = self._search_sources.get(key)
|
||||||
if provider is None:
|
if provider is None:
|
||||||
provider = registered.registration.create(
|
provider = registered.registration.create(self._capability_context)
|
||||||
self._capability_context
|
|
||||||
)
|
|
||||||
self._search_sources[key] = provider
|
self._search_sources[key] = provider
|
||||||
providers.append((registered, provider))
|
providers.append((registered, provider))
|
||||||
return tuple(providers)
|
return tuple(providers)
|
||||||
|
|
||||||
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
|
def work_item_provider_registrations(
|
||||||
|
self,
|
||||||
|
) -> tuple[RegisteredWorkItemProvider, ...]:
|
||||||
|
return tuple(
|
||||||
|
sorted(
|
||||||
|
self._work_item_provider_registrations,
|
||||||
|
key=lambda item: (
|
||||||
|
item.registration.order,
|
||||||
|
item.module_id,
|
||||||
|
item.registration.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def work_item_providers(
|
||||||
|
self,
|
||||||
|
) -> tuple[tuple[RegisteredWorkItemProvider, WorkItemProvider], ...]:
|
||||||
|
if self._capability_context is None:
|
||||||
|
if self._work_item_provider_registrations:
|
||||||
|
raise RegistryError("Work-item provider context is not configured.")
|
||||||
|
return ()
|
||||||
|
providers: list[tuple[RegisteredWorkItemProvider, WorkItemProvider]] = []
|
||||||
|
for registered in self.work_item_provider_registrations():
|
||||||
|
execution = current_tenant_execution_context()
|
||||||
|
if execution is not None:
|
||||||
|
try:
|
||||||
|
execution.require_module(registered.module_id)
|
||||||
|
except TenantModuleUnavailable:
|
||||||
|
continue
|
||||||
|
key = f"{registered.module_id}:{registered.registration.id}"
|
||||||
|
provider = self._work_item_providers.get(key)
|
||||||
|
if provider is None:
|
||||||
|
provider = registered.registration.create(self._capability_context)
|
||||||
|
self._work_item_providers[key] = provider
|
||||||
|
providers.append((registered, provider))
|
||||||
|
return tuple(providers)
|
||||||
|
|
||||||
|
def register_tenant_summary_provider(
|
||||||
|
self, module_id: str, provider: TenantSummaryProvider
|
||||||
|
) -> None:
|
||||||
self._tenant_summary_providers[module_id] = provider
|
self._tenant_summary_providers[module_id] = provider
|
||||||
|
|
||||||
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
def tenant_summary_providers(self) -> Mapping[str, TenantSummaryProvider]:
|
||||||
@@ -488,37 +569,56 @@ class PlatformRegistry:
|
|||||||
) -> Mapping[str, TenantSummaryBatchProvider]:
|
) -> Mapping[str, TenantSummaryBatchProvider]:
|
||||||
return dict(self._tenant_summary_batch_providers)
|
return dict(self._tenant_summary_batch_providers)
|
||||||
|
|
||||||
def register_delete_veto(self, module_id: str, resource_type: str, provider: DeleteVetoProvider) -> None:
|
def register_delete_veto(
|
||||||
self._delete_veto_providers[resource_type].append(DeleteVetoProviderRegistration(
|
self, module_id: str, resource_type: str, provider: DeleteVetoProvider
|
||||||
module_id=module_id,
|
) -> None:
|
||||||
resource_type=resource_type,
|
self._delete_veto_providers[resource_type].append(
|
||||||
provider=provider,
|
DeleteVetoProviderRegistration(
|
||||||
))
|
module_id=module_id,
|
||||||
|
resource_type=resource_type,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def delete_veto_providers(self, resource_type: str) -> tuple[DeleteVetoProvider, ...]:
|
def delete_veto_providers(
|
||||||
return tuple(registration.provider for registration in self._delete_veto_providers.get(resource_type, ()))
|
self, resource_type: str
|
||||||
|
) -> tuple[DeleteVetoProvider, ...]:
|
||||||
|
return tuple(
|
||||||
|
registration.provider
|
||||||
|
for registration in self._delete_veto_providers.get(resource_type, ())
|
||||||
|
)
|
||||||
|
|
||||||
def delete_veto_provider_registrations(self, resource_type: str) -> tuple[DeleteVetoProviderRegistration, ...]:
|
def delete_veto_provider_registrations(
|
||||||
|
self, resource_type: str
|
||||||
|
) -> tuple[DeleteVetoProviderRegistration, ...]:
|
||||||
return tuple(self._delete_veto_providers.get(resource_type, ()))
|
return tuple(self._delete_veto_providers.get(resource_type, ()))
|
||||||
|
|
||||||
def collect_delete_veto_issues(self, resource_type: str, session: object, tenant_id: str, resource_id: str) -> tuple[DeleteVetoIssue, ...]:
|
def collect_delete_veto_issues(
|
||||||
|
self, resource_type: str, session: object, tenant_id: str, resource_id: str
|
||||||
|
) -> tuple[DeleteVetoIssue, ...]:
|
||||||
issues: list[DeleteVetoIssue] = []
|
issues: list[DeleteVetoIssue] = []
|
||||||
for registration in self.delete_veto_provider_registrations(resource_type):
|
for registration in self.delete_veto_provider_registrations(resource_type):
|
||||||
try:
|
try:
|
||||||
result = registration.provider(session, tenant_id, resource_id)
|
result = registration.provider(session, tenant_id, resource_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
issues.append(DeleteVetoIssue(
|
issues.append(
|
||||||
severity="blocker",
|
DeleteVetoIssue(
|
||||||
code="module_veto",
|
severity="blocker",
|
||||||
message=str(exc),
|
code="module_veto",
|
||||||
module_id=registration.module_id,
|
message=str(exc),
|
||||||
details={
|
module_id=registration.module_id,
|
||||||
"resource_type": resource_type,
|
details={
|
||||||
"resource_id": resource_id,
|
"resource_type": resource_type,
|
||||||
},
|
"resource_id": resource_id,
|
||||||
))
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
issues.extend(_normalize_delete_veto_result(result, registration=registration, resource_id=resource_id))
|
issues.extend(
|
||||||
|
_normalize_delete_veto_result(
|
||||||
|
result, registration=registration, resource_id=resource_id
|
||||||
|
)
|
||||||
|
)
|
||||||
return tuple(issues)
|
return tuple(issues)
|
||||||
|
|
||||||
def validate(self) -> RegistrySnapshot:
|
def validate(self) -> RegistrySnapshot:
|
||||||
@@ -539,18 +639,29 @@ class PlatformRegistry:
|
|||||||
return RegistrySnapshot(
|
return RegistrySnapshot(
|
||||||
manifests=ordered,
|
manifests=ordered,
|
||||||
permissions=tuple(permissions.values()),
|
permissions=tuple(permissions.values()),
|
||||||
role_templates=tuple(template for manifest in ordered for template in manifest.role_templates),
|
role_templates=tuple(
|
||||||
|
template for manifest in ordered for template in manifest.role_templates
|
||||||
|
),
|
||||||
nav_items=self.nav_items(),
|
nav_items=self.nav_items(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _topologically_sorted(self) -> Iterable[ModuleManifest]:
|
def _topologically_sorted(self) -> Iterable[ModuleManifest]:
|
||||||
incoming: dict[str, set[str]] = {module_id: set(manifest.dependencies) for module_id, manifest in self._manifests.items()}
|
incoming: dict[str, set[str]] = {
|
||||||
|
module_id: set(manifest.dependencies)
|
||||||
|
for module_id, manifest in self._manifests.items()
|
||||||
|
}
|
||||||
dependents: dict[str, set[str]] = defaultdict(set)
|
dependents: dict[str, set[str]] = defaultdict(set)
|
||||||
for module_id, dependencies in incoming.items():
|
for module_id, dependencies in incoming.items():
|
||||||
for dependency in dependencies:
|
for dependency in dependencies:
|
||||||
dependents[dependency].add(module_id)
|
dependents[dependency].add(module_id)
|
||||||
|
|
||||||
ready = deque(sorted(module_id for module_id, dependencies in incoming.items() if not dependencies))
|
ready = deque(
|
||||||
|
sorted(
|
||||||
|
module_id
|
||||||
|
for module_id, dependencies in incoming.items()
|
||||||
|
if not dependencies
|
||||||
|
)
|
||||||
|
)
|
||||||
ordered: list[str] = []
|
ordered: list[str] = []
|
||||||
while ready:
|
while ready:
|
||||||
module_id = ready.popleft()
|
module_id = ready.popleft()
|
||||||
@@ -561,8 +672,16 @@ class PlatformRegistry:
|
|||||||
ready.append(dependent)
|
ready.append(dependent)
|
||||||
|
|
||||||
if len(ordered) != len(self._manifests):
|
if len(ordered) != len(self._manifests):
|
||||||
unresolved = ", ".join(sorted(module_id for module_id, dependencies in incoming.items() if dependencies))
|
unresolved = ", ".join(
|
||||||
raise RegistryError(f"Module dependency cycle or unresolved dependency: {unresolved}")
|
sorted(
|
||||||
|
module_id
|
||||||
|
for module_id, dependencies in incoming.items()
|
||||||
|
if dependencies
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module dependency cycle or unresolved dependency: {unresolved}"
|
||||||
|
)
|
||||||
return (self._manifests[module_id] for module_id in ordered)
|
return (self._manifests[module_id] for module_id in ordered)
|
||||||
|
|
||||||
|
|
||||||
@@ -622,21 +741,29 @@ def _normalize_delete_veto_result(
|
|||||||
if result is None:
|
if result is None:
|
||||||
return ()
|
return ()
|
||||||
if isinstance(result, DeleteVetoIssue):
|
if isinstance(result, DeleteVetoIssue):
|
||||||
return (_attribute_delete_veto_issue(result, registration=registration, resource_id=resource_id),)
|
return (
|
||||||
|
_attribute_delete_veto_issue(
|
||||||
|
result, registration=registration, resource_id=resource_id
|
||||||
|
),
|
||||||
|
)
|
||||||
if isinstance(result, str):
|
if isinstance(result, str):
|
||||||
return (DeleteVetoIssue(
|
return (
|
||||||
severity="blocker",
|
DeleteVetoIssue(
|
||||||
code="module_veto",
|
severity="blocker",
|
||||||
message=result,
|
code="module_veto",
|
||||||
module_id=registration.module_id,
|
message=result,
|
||||||
details={
|
module_id=registration.module_id,
|
||||||
"resource_type": registration.resource_type,
|
details={
|
||||||
"resource_id": resource_id,
|
"resource_type": registration.resource_type,
|
||||||
},
|
"resource_id": resource_id,
|
||||||
),)
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
if isinstance(result, Iterable):
|
if isinstance(result, Iterable):
|
||||||
return tuple(
|
return tuple(
|
||||||
_attribute_delete_veto_issue(issue, registration=registration, resource_id=resource_id)
|
_attribute_delete_veto_issue(
|
||||||
|
issue, registration=registration, resource_id=resource_id
|
||||||
|
)
|
||||||
for issue in result
|
for issue in result
|
||||||
if isinstance(issue, DeleteVetoIssue)
|
if isinstance(issue, DeleteVetoIssue)
|
||||||
)
|
)
|
||||||
@@ -656,7 +783,9 @@ def _attribute_delete_veto_issue(
|
|||||||
}
|
}
|
||||||
if issue.module_id == registration.module_id and issue.details == details:
|
if issue.module_id == registration.module_id and issue.details == details:
|
||||||
return issue
|
return issue
|
||||||
return replace(issue, module_id=issue.module_id or registration.module_id, details=details)
|
return replace(
|
||||||
|
issue, module_id=issue.module_id or registration.module_id, details=details
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_relationships(
|
def _validate_manifest_relationships(
|
||||||
@@ -667,16 +796,24 @@ def _validate_manifest_relationships(
|
|||||||
) -> None:
|
) -> None:
|
||||||
for dependency in manifest.dependencies:
|
for dependency in manifest.dependencies:
|
||||||
if dependency not in known_modules:
|
if dependency not in known_modules:
|
||||||
raise RegistryError(f"Module {manifest.id!r} depends on unknown module {dependency!r}")
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} depends on unknown module {dependency!r}"
|
||||||
|
)
|
||||||
for capability in manifest.required_capabilities:
|
for capability in manifest.required_capabilities:
|
||||||
if capability not in available_capabilities:
|
if capability not in available_capabilities:
|
||||||
raise RegistryError(f"Module {manifest.id!r} requires unavailable capability {capability!r}")
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} requires unavailable capability {capability!r}"
|
||||||
|
)
|
||||||
for dependency in manifest.optional_dependencies:
|
for dependency in manifest.optional_dependencies:
|
||||||
if dependency == manifest.id:
|
if dependency == manifest.id:
|
||||||
raise RegistryError(f"Module {manifest.id!r} cannot list itself as an optional dependency")
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} cannot list itself as an optional dependency"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _collect_manifest_permissions(manifests: tuple[ModuleManifest, ...]) -> dict[str, PermissionDefinition]:
|
def _collect_manifest_permissions(
|
||||||
|
manifests: tuple[ModuleManifest, ...],
|
||||||
|
) -> dict[str, PermissionDefinition]:
|
||||||
permissions: dict[str, PermissionDefinition] = {}
|
permissions: dict[str, PermissionDefinition] = {}
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
for permission in manifest.permissions:
|
for permission in manifest.permissions:
|
||||||
@@ -697,10 +834,17 @@ def _validate_manifest_permission(
|
|||||||
f"{permission.module_id!r}; expected {permission_namespace!r}"
|
f"{permission.module_id!r}; expected {permission_namespace!r}"
|
||||||
)
|
)
|
||||||
if not _SCOPE_RE.match(permission.scope):
|
if not _SCOPE_RE.match(permission.scope):
|
||||||
raise RegistryError(f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}")
|
raise RegistryError(
|
||||||
|
f"Permission scope must be <module>:<resource>:<action>: {permission.scope!r}"
|
||||||
|
)
|
||||||
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
expected_prefix = f"{permission.module_id}:{permission.resource}:"
|
||||||
if not permission.scope.startswith(expected_prefix) or permission.scope.rsplit(":", 1)[-1] != permission.action:
|
if (
|
||||||
raise RegistryError(f"Permission fields do not match scope {permission.scope!r}")
|
not permission.scope.startswith(expected_prefix)
|
||||||
|
or permission.scope.rsplit(":", 1)[-1] != permission.action
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Permission fields do not match scope {permission.scope!r}"
|
||||||
|
)
|
||||||
if permission.scope in seen_permissions:
|
if permission.scope in seen_permissions:
|
||||||
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
||||||
|
|
||||||
@@ -731,8 +875,7 @@ def _validate_role_template_scopes(
|
|||||||
)
|
)
|
||||||
for scope in template.permissions:
|
for scope in template.permissions:
|
||||||
if template.default_authenticated and (
|
if template.default_authenticated and (
|
||||||
scope in {"*", "tenant:*", "system:*"}
|
scope in {"*", "tenant:*", "system:*"} or _WILDCARD_RE.match(scope)
|
||||||
or _WILDCARD_RE.match(scope)
|
|
||||||
):
|
):
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Default authenticated role template {template.slug!r} "
|
f"Default authenticated role template {template.slug!r} "
|
||||||
@@ -740,7 +883,9 @@ def _validate_role_template_scopes(
|
|||||||
)
|
)
|
||||||
if _role_template_scope_known(scope, known_scopes):
|
if _role_template_scope_known(scope, known_scopes):
|
||||||
continue
|
continue
|
||||||
raise RegistryError(f"Role template {template.slug!r} references unknown permission {scope!r}")
|
raise RegistryError(
|
||||||
|
f"Role template {template.slug!r} references unknown permission {scope!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
|
def _role_template_scope_known(scope: str, known_scopes: set[str]) -> bool:
|
||||||
@@ -882,9 +1027,7 @@ def _validate_architecture_declarations(manifest: ModuleManifest) -> None:
|
|||||||
f"Module {manifest.id!r} provider authority modes are not declared "
|
f"Module {manifest.id!r} provider authority modes are not declared "
|
||||||
"by its architecture metadata: " + ", ".join(sorted(missing_modes))
|
"by its architecture metadata: " + ", ".join(sorted(missing_modes))
|
||||||
)
|
)
|
||||||
unknown_target_providers = set(
|
unknown_target_providers = set(architecture.target_tested_providers) - provider_ids
|
||||||
architecture.target_tested_providers
|
|
||||||
) - provider_ids
|
|
||||||
if unknown_target_providers:
|
if unknown_target_providers:
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} target-tested providers are not declared: "
|
f"Module {manifest.id!r} target-tested providers are not declared: "
|
||||||
@@ -961,8 +1104,7 @@ def _validate_workflow_definition_contributions(
|
|||||||
contribution.definition_key,
|
contribution.definition_key,
|
||||||
):
|
):
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Workflow contribution key is invalid: "
|
f"Workflow contribution key is invalid: {contribution.definition_key!r}"
|
||||||
f"{contribution.definition_key!r}"
|
|
||||||
)
|
)
|
||||||
if contribution.definition_key in seen_keys:
|
if contribution.definition_key in seen_keys:
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
@@ -1003,7 +1145,10 @@ def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
|
|||||||
f"Module {manifest.id!r} capability {capability!r} "
|
f"Module {manifest.id!r} capability {capability!r} "
|
||||||
"documentation must have a summary"
|
"documentation must have a summary"
|
||||||
)
|
)
|
||||||
if metadata.contract_version is not None and not metadata.contract_version.strip():
|
if (
|
||||||
|
metadata.contract_version is not None
|
||||||
|
and not metadata.contract_version.strip()
|
||||||
|
):
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} capability {capability!r} "
|
f"Module {manifest.id!r} capability {capability!r} "
|
||||||
"documentation contract version must not be empty"
|
"documentation contract version must not be empty"
|
||||||
@@ -1046,20 +1191,24 @@ def _validate_documentation_extensions(manifest: ModuleManifest) -> None:
|
|||||||
|
|
||||||
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
||||||
if not _MODULE_ID_RE.match(manifest.id):
|
if not _MODULE_ID_RE.match(manifest.id):
|
||||||
raise RegistryError(f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}")
|
raise RegistryError(
|
||||||
|
f"Module manifest id must match {_MODULE_ID_RE.pattern}: {manifest.id!r}"
|
||||||
|
)
|
||||||
if not manifest.name.strip():
|
if not manifest.name.strip():
|
||||||
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
|
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty name")
|
||||||
if not manifest.version.strip():
|
if not manifest.version.strip():
|
||||||
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
|
raise RegistryError(f"Module {manifest.id!r} must declare a non-empty version")
|
||||||
if (
|
if manifest.permission_namespace is not None and not _MODULE_ID_RE.match(
|
||||||
manifest.permission_namespace is not None
|
manifest.permission_namespace
|
||||||
and not _MODULE_ID_RE.match(manifest.permission_namespace)
|
|
||||||
):
|
):
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} has invalid permission namespace "
|
f"Module {manifest.id!r} has invalid permission namespace "
|
||||||
f"{manifest.permission_namespace!r}"
|
f"{manifest.permission_namespace!r}"
|
||||||
)
|
)
|
||||||
if manifest.compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
|
if (
|
||||||
|
manifest.compatibility.manifest_contract_version
|
||||||
|
!= SUPPORTED_MANIFEST_CONTRACT_VERSION
|
||||||
|
):
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} uses unsupported manifest contract version "
|
f"Module {manifest.id!r} uses unsupported manifest contract version "
|
||||||
f"{manifest.compatibility.manifest_contract_version!r}; supported version is "
|
f"{manifest.compatibility.manifest_contract_version!r}; supported version is "
|
||||||
@@ -1069,9 +1218,15 @@ def _validate_manifest_identity(manifest: ModuleManifest) -> None:
|
|||||||
|
|
||||||
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
||||||
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
_validate_dependency_list(manifest.id, "dependencies", manifest.dependencies)
|
||||||
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
_validate_dependency_list(
|
||||||
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
manifest.id, "optional_dependencies", manifest.optional_dependencies
|
||||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
)
|
||||||
|
_validate_capability_list(
|
||||||
|
manifest.id, "required_capabilities", manifest.required_capabilities
|
||||||
|
)
|
||||||
|
_validate_capability_list(
|
||||||
|
manifest.id, "optional_capabilities", manifest.optional_capabilities
|
||||||
|
)
|
||||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||||
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
||||||
provider_ids: set[str] = set()
|
provider_ids: set[str] = set()
|
||||||
@@ -1100,25 +1255,51 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
|
|||||||
f"{registration.id!r}"
|
f"{registration.id!r}"
|
||||||
)
|
)
|
||||||
source_ids.add(registration.id)
|
source_ids.add(registration.id)
|
||||||
|
work_provider_ids: set[str] = set()
|
||||||
|
for registration in manifest.work_item_providers:
|
||||||
|
if not _INTERFACE_NAME_RE.match(registration.id):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} work-item provider id must be "
|
||||||
|
f"namespaced: {registration.id!r}"
|
||||||
|
)
|
||||||
|
if registration.id in work_provider_ids:
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} declares duplicate work-item provider "
|
||||||
|
f"{registration.id!r}"
|
||||||
|
)
|
||||||
|
work_provider_ids.add(registration.id)
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:
|
||||||
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||||
if overlap:
|
if overlap:
|
||||||
joined = ", ".join(sorted(overlap))
|
joined = ", ".join(sorted(overlap))
|
||||||
raise RegistryError(f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}")
|
raise RegistryError(
|
||||||
capability_overlap = set(manifest.required_capabilities) & set(manifest.optional_capabilities)
|
f"Module {manifest.id!r} lists dependencies as both required and optional: {joined}"
|
||||||
|
)
|
||||||
|
capability_overlap = set(manifest.required_capabilities) & set(
|
||||||
|
manifest.optional_capabilities
|
||||||
|
)
|
||||||
if capability_overlap:
|
if capability_overlap:
|
||||||
joined = ", ".join(sorted(capability_overlap))
|
joined = ", ".join(sorted(capability_overlap))
|
||||||
raise RegistryError(f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}")
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} lists capabilities as both required and optional: {joined}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
|
def _validate_manifest_migration_spec(manifest: ModuleManifest) -> None:
|
||||||
if manifest.migration_spec is not None:
|
if manifest.migration_spec is not None:
|
||||||
if manifest.migration_spec.module_id != manifest.id:
|
if manifest.migration_spec.module_id != manifest.id:
|
||||||
raise RegistryError(f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}")
|
raise RegistryError(
|
||||||
if manifest.migration_spec.metadata is None and not manifest.migration_spec.script_location:
|
f"Module {manifest.id!r} has migration spec for {manifest.migration_spec.module_id!r}"
|
||||||
raise RegistryError(f"Module {manifest.id!r} migration spec must declare metadata or script location")
|
)
|
||||||
|
if (
|
||||||
|
manifest.migration_spec.metadata is None
|
||||||
|
and not manifest.migration_spec.script_location
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} migration spec must declare metadata or script location"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
||||||
@@ -1126,15 +1307,24 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None:
|
|||||||
return
|
return
|
||||||
frontend = manifest.frontend
|
frontend = manifest.frontend
|
||||||
if frontend.module_id != manifest.id:
|
if frontend.module_id != manifest.id:
|
||||||
raise RegistryError(f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}")
|
raise RegistryError(
|
||||||
if frontend.asset_manifest_contract_version != SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION:
|
f"Module {manifest.id!r} has frontend metadata for {frontend.module_id!r}"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
frontend.asset_manifest_contract_version
|
||||||
|
!= SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION
|
||||||
|
):
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
|
f"Module {manifest.id!r} uses unsupported frontend asset manifest contract version "
|
||||||
f"{frontend.asset_manifest_contract_version!r}; supported version is "
|
f"{frontend.asset_manifest_contract_version!r}; supported version is "
|
||||||
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
|
f"{SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION!r}"
|
||||||
)
|
)
|
||||||
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name):
|
if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(
|
||||||
raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}")
|
frontend.package_name
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}"
|
||||||
|
)
|
||||||
if frontend.public_routes and manifest.public_tenant_resolver is None:
|
if frontend.public_routes and manifest.public_tenant_resolver is None:
|
||||||
raise RegistryError(
|
raise RegistryError(
|
||||||
f"Module {manifest.id!r} exposes public frontend routes without a "
|
f"Module {manifest.id!r} exposes public frontend routes without a "
|
||||||
@@ -1160,7 +1350,9 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
|||||||
known_ids = {root_id}
|
known_ids = {root_id}
|
||||||
_validate_view_surface_id(manifest.id, root_id)
|
_validate_view_surface_id(manifest.id, root_id)
|
||||||
for item in frontend.nav_items:
|
for item in frontend.nav_items:
|
||||||
surface_id = item.surface_id or navigation_view_surface_id(manifest.id, item.path)
|
surface_id = item.surface_id or navigation_view_surface_id(
|
||||||
|
manifest.id, item.path
|
||||||
|
)
|
||||||
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
_register_view_surface_id(manifest.id, surface_id, known_ids)
|
||||||
for route in (*frontend.routes, *frontend.settings_routes):
|
for route in (*frontend.routes, *frontend.settings_routes):
|
||||||
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
|
surface_id = route.surface_id or route_view_surface_id(manifest.id, route.path)
|
||||||
@@ -1176,8 +1368,7 @@ def _validate_view_surfaces(manifest: ModuleManifest) -> None:
|
|||||||
known_ids=known_ids,
|
known_ids=known_ids,
|
||||||
)
|
)
|
||||||
parent_by_id = {
|
parent_by_id = {
|
||||||
surface.id: surface.parent_id or root_id
|
surface.id: surface.parent_id or root_id for surface in frontend.view_surfaces
|
||||||
for surface in frontend.view_surfaces
|
|
||||||
}
|
}
|
||||||
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
|
_validate_view_surface_hierarchy(manifest.id, parent_by_id)
|
||||||
|
|
||||||
@@ -1257,9 +1448,13 @@ def _validate_view_surface_id(module_id: str, surface_id: str) -> None:
|
|||||||
|
|
||||||
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
def _validate_frontend_route(module_id: str, path: str, component: str) -> None:
|
||||||
if not path.startswith("/"):
|
if not path.startswith("/"):
|
||||||
raise RegistryError(f"Frontend route for module {module_id!r} must start with '/': {path!r}")
|
raise RegistryError(
|
||||||
|
f"Frontend route for module {module_id!r} must start with '/': {path!r}"
|
||||||
|
)
|
||||||
if not component.strip():
|
if not component.strip():
|
||||||
raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component")
|
raise RegistryError(
|
||||||
|
f"Frontend route {path!r} for module {module_id!r} must declare a component"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_public_frontend_route_uniqueness(
|
def _validate_public_frontend_route_uniqueness(
|
||||||
@@ -1281,7 +1476,9 @@ def _validate_public_frontend_route_uniqueness(
|
|||||||
|
|
||||||
|
|
||||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = (
|
||||||
|
defaultdict(list)
|
||||||
|
)
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
for provided in manifest.provides_interfaces:
|
for provided in manifest.provides_interfaces:
|
||||||
providers[provided.name].append((manifest, provided))
|
providers[provided.name].append((manifest, provided))
|
||||||
@@ -1321,44 +1518,69 @@ def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_dependency_list(module_id: str, field_name: str, dependencies: tuple[str, ...]) -> None:
|
def _validate_dependency_list(
|
||||||
|
module_id: str, field_name: str, dependencies: tuple[str, ...]
|
||||||
|
) -> None:
|
||||||
if len(dependencies) != len(set(dependencies)):
|
if len(dependencies) != len(set(dependencies)):
|
||||||
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
|
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
|
||||||
for dependency in dependencies:
|
for dependency in dependencies:
|
||||||
if dependency == module_id:
|
if dependency == module_id:
|
||||||
raise RegistryError(f"Module {module_id!r} cannot depend on itself")
|
raise RegistryError(f"Module {module_id!r} cannot depend on itself")
|
||||||
if not _MODULE_ID_RE.match(dependency):
|
if not _MODULE_ID_RE.match(dependency):
|
||||||
raise RegistryError(f"Module {module_id!r} has invalid dependency id {dependency!r}")
|
raise RegistryError(
|
||||||
|
f"Module {module_id!r} has invalid dependency id {dependency!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_capability_list(module_id: str, field_name: str, capabilities: tuple[str, ...]) -> None:
|
def _validate_capability_list(
|
||||||
|
module_id: str, field_name: str, capabilities: tuple[str, ...]
|
||||||
|
) -> None:
|
||||||
if len(capabilities) != len(set(capabilities)):
|
if len(capabilities) != len(set(capabilities)):
|
||||||
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
|
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
|
||||||
for capability in capabilities:
|
for capability in capabilities:
|
||||||
if not capability.strip() or any(part == "" for part in capability.split(".")):
|
if not capability.strip() or any(part == "" for part in capability.split(".")):
|
||||||
raise RegistryError(f"Module {module_id!r} has invalid capability name {capability!r}")
|
raise RegistryError(
|
||||||
|
f"Module {module_id!r} has invalid capability name {capability!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_interface_providers(module_id: str, providers: tuple[ModuleInterfaceProvider, ...]) -> None:
|
def _validate_interface_providers(
|
||||||
|
module_id: str, providers: tuple[ModuleInterfaceProvider, ...]
|
||||||
|
) -> None:
|
||||||
names = [provider.name for provider in providers]
|
names = [provider.name for provider in providers]
|
||||||
if len(names) != len(set(names)):
|
if len(names) != len(set(names)):
|
||||||
raise RegistryError(f"Module {module_id!r} has duplicate provided interface names")
|
raise RegistryError(
|
||||||
|
f"Module {module_id!r} has duplicate provided interface names"
|
||||||
|
)
|
||||||
for provider in providers:
|
for provider in providers:
|
||||||
_validate_interface_name(module_id, provider.name)
|
_validate_interface_name(module_id, provider.name)
|
||||||
if not provider.version.strip():
|
if not provider.version.strip():
|
||||||
raise RegistryError(f"Module {module_id!r} provides interface {provider.name!r} without a version")
|
raise RegistryError(
|
||||||
|
f"Module {module_id!r} provides interface {provider.name!r} without a version"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_interface_requirements(module_id: str, requirements: tuple[ModuleInterfaceRequirement, ...]) -> None:
|
def _validate_interface_requirements(
|
||||||
|
module_id: str, requirements: tuple[ModuleInterfaceRequirement, ...]
|
||||||
|
) -> None:
|
||||||
names = [requirement.name for requirement in requirements]
|
names = [requirement.name for requirement in requirements]
|
||||||
if len(names) != len(set(names)):
|
if len(names) != len(set(names)):
|
||||||
raise RegistryError(f"Module {module_id!r} has duplicate required interface names")
|
raise RegistryError(
|
||||||
|
f"Module {module_id!r} has duplicate required interface names"
|
||||||
|
)
|
||||||
for requirement in requirements:
|
for requirement in requirements:
|
||||||
_validate_interface_name(module_id, requirement.name)
|
_validate_interface_name(module_id, requirement.name)
|
||||||
if requirement.version_min is not None and not requirement.version_min.strip():
|
if requirement.version_min is not None and not requirement.version_min.strip():
|
||||||
raise RegistryError(f"Module {module_id!r} has blank minimum version for interface {requirement.name!r}")
|
raise RegistryError(
|
||||||
if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip():
|
f"Module {module_id!r} has blank minimum version for interface {requirement.name!r}"
|
||||||
raise RegistryError(f"Module {module_id!r} has blank maximum version for interface {requirement.name!r}")
|
)
|
||||||
|
if (
|
||||||
|
requirement.version_max_exclusive is not None
|
||||||
|
and not requirement.version_max_exclusive.strip()
|
||||||
|
):
|
||||||
|
raise RegistryError(
|
||||||
|
f"Module {module_id!r} has blank maximum version for interface {requirement.name!r}"
|
||||||
|
)
|
||||||
if not version_range_is_valid(
|
if not version_range_is_valid(
|
||||||
version_min=requirement.version_min,
|
version_min=requirement.version_min,
|
||||||
version_max_exclusive=requirement.version_max_exclusive,
|
version_max_exclusive=requirement.version_max_exclusive,
|
||||||
@@ -1367,7 +1589,9 @@ def _validate_interface_requirements(module_id: str, requirements: tuple[ModuleI
|
|||||||
version_min=requirement.version_min,
|
version_min=requirement.version_min,
|
||||||
version_max_exclusive=requirement.version_max_exclusive,
|
version_max_exclusive=requirement.version_max_exclusive,
|
||||||
)
|
)
|
||||||
raise RegistryError(f"Module {module_id!r} has invalid interface range {version_range!r}")
|
raise RegistryError(
|
||||||
|
f"Module {module_id!r} has invalid interface range {version_range!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_interface_name(module_id: str, name: str) -> None:
|
def _validate_interface_name(module_id: str, name: str) -> None:
|
||||||
@@ -1377,8 +1601,14 @@ def _validate_interface_name(module_id: str, name: str) -> None:
|
|||||||
|
|
||||||
def _validate_nav_item(module_id: str, item: NavItem) -> None:
|
def _validate_nav_item(module_id: str, item: NavItem) -> None:
|
||||||
if not item.path.startswith("/"):
|
if not item.path.startswith("/"):
|
||||||
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")
|
raise RegistryError(
|
||||||
|
f"Navigation item for module {module_id!r} must start with '/': {item.path!r}"
|
||||||
|
)
|
||||||
if not item.label.strip():
|
if not item.label.strip():
|
||||||
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} must declare a label")
|
raise RegistryError(
|
||||||
|
f"Navigation item {item.path!r} for module {module_id!r} must declare a label"
|
||||||
|
)
|
||||||
if item.icon is not None and not item.icon.strip():
|
if item.icon is not None and not item.icon.strip():
|
||||||
raise RegistryError(f"Navigation item {item.path!r} for module {module_id!r} has an empty icon name")
|
raise RegistryError(
|
||||||
|
f"Navigation item {item.path!r} for module {module_id!r} has an empty icon name"
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleContext
|
||||||
|
|
||||||
|
|
||||||
|
WorkItemStatus = Literal[
|
||||||
|
"open",
|
||||||
|
"in_progress",
|
||||||
|
"deferred",
|
||||||
|
"blocked",
|
||||||
|
"completed",
|
||||||
|
"cancelled",
|
||||||
|
]
|
||||||
|
WorkItemPriority = Literal["low", "normal", "high", "urgent"]
|
||||||
|
WorkAssignmentKind = Literal[
|
||||||
|
"account",
|
||||||
|
"group",
|
||||||
|
"role",
|
||||||
|
"function",
|
||||||
|
"function_assignment",
|
||||||
|
"anyone",
|
||||||
|
]
|
||||||
|
WORK_ITEM_CONTRACT_VERSION = "1"
|
||||||
|
CAPABILITY_TASK_COMMANDS = "tasks.commands"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkAssignmentRef:
|
||||||
|
kind: WorkAssignmentKind
|
||||||
|
id: str
|
||||||
|
label: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.kind not in {
|
||||||
|
"account",
|
||||||
|
"group",
|
||||||
|
"role",
|
||||||
|
"function",
|
||||||
|
"function_assignment",
|
||||||
|
"anyone",
|
||||||
|
}:
|
||||||
|
raise ValueError(f"Unsupported work-assignment kind: {self.kind!r}.")
|
||||||
|
if not self.id.strip():
|
||||||
|
raise ValueError("Work assignments require an id.")
|
||||||
|
if len(self.id) > 255:
|
||||||
|
raise ValueError("Work assignment ids are limited to 255 characters.")
|
||||||
|
if self.label is not None and len(self.label) > 500:
|
||||||
|
raise ValueError("Work assignment labels are limited to 500 characters.")
|
||||||
|
if self.kind == "anyone" and self.id != "*":
|
||||||
|
raise ValueError("Broad work assignments use the canonical '*' id.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkSourceRef:
|
||||||
|
module_id: str
|
||||||
|
resource_type: str
|
||||||
|
resource_id: str
|
||||||
|
revision: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
label: str | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
required = (self.module_id, self.resource_type, self.resource_id)
|
||||||
|
if any(not value.strip() for value in required):
|
||||||
|
raise ValueError("Work source references require module, type, and id.")
|
||||||
|
limits = {
|
||||||
|
"module_id": 100,
|
||||||
|
"resource_type": 100,
|
||||||
|
"resource_id": 255,
|
||||||
|
"revision": 255,
|
||||||
|
"url": 1_500,
|
||||||
|
"label": 500,
|
||||||
|
}
|
||||||
|
for field_name, limit in limits.items():
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if value is not None and len(value) > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"Work source {field_name} is limited to {limit} characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkItem:
|
||||||
|
id: str
|
||||||
|
provider_id: str
|
||||||
|
owner_module: str
|
||||||
|
tenant_id: str
|
||||||
|
title: str
|
||||||
|
status: WorkItemStatus = "open"
|
||||||
|
priority: WorkItemPriority = "normal"
|
||||||
|
summary: str | None = None
|
||||||
|
required_action: str | None = None
|
||||||
|
action_url: str | None = None
|
||||||
|
due_at: datetime | None = None
|
||||||
|
deferred_until: datetime | None = None
|
||||||
|
assignments: tuple[WorkAssignmentRef, ...] = ()
|
||||||
|
sources: tuple[WorkSourceRef, ...] = ()
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
revision: str = "1"
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
required = {
|
||||||
|
"id": self.id,
|
||||||
|
"provider_id": self.provider_id,
|
||||||
|
"owner_module": self.owner_module,
|
||||||
|
"tenant_id": self.tenant_id,
|
||||||
|
"title": self.title,
|
||||||
|
"revision": self.revision,
|
||||||
|
}
|
||||||
|
if any(not value.strip() for value in required.values()):
|
||||||
|
raise ValueError(
|
||||||
|
"Work items require stable identity, owner, tenant, and title."
|
||||||
|
)
|
||||||
|
limits = {
|
||||||
|
"id": 255,
|
||||||
|
"provider_id": 200,
|
||||||
|
"owner_module": 100,
|
||||||
|
"tenant_id": 255,
|
||||||
|
"title": 500,
|
||||||
|
"summary": 4_000,
|
||||||
|
"required_action": 500,
|
||||||
|
"action_url": 1_500,
|
||||||
|
"revision": 255,
|
||||||
|
}
|
||||||
|
for field_name, limit in limits.items():
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if value is not None and len(value) > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"Work item {field_name} is limited to {limit} characters."
|
||||||
|
)
|
||||||
|
if len(self.assignments) > 100 or len(self.sources) > 100:
|
||||||
|
raise ValueError("Work items support at most 100 assignments and sources.")
|
||||||
|
_validate_action_url(self.action_url)
|
||||||
|
if self.status not in {
|
||||||
|
"open",
|
||||||
|
"in_progress",
|
||||||
|
"deferred",
|
||||||
|
"blocked",
|
||||||
|
"completed",
|
||||||
|
"cancelled",
|
||||||
|
}:
|
||||||
|
raise ValueError(f"Unsupported work-item status: {self.status!r}.")
|
||||||
|
if self.priority not in {"low", "normal", "high", "urgent"}:
|
||||||
|
raise ValueError(f"Unsupported work-item priority: {self.priority!r}.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkItemQuery:
|
||||||
|
tenant_id: str
|
||||||
|
statuses: tuple[WorkItemStatus, ...] = (
|
||||||
|
"open",
|
||||||
|
"in_progress",
|
||||||
|
"deferred",
|
||||||
|
"blocked",
|
||||||
|
)
|
||||||
|
priorities: tuple[WorkItemPriority, ...] = ()
|
||||||
|
provider_ids: tuple[str, ...] = ()
|
||||||
|
owner_modules: tuple[str, ...] = ()
|
||||||
|
due_before: datetime | None = None
|
||||||
|
text: str = ""
|
||||||
|
limit: int = 100
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.tenant_id.strip():
|
||||||
|
raise ValueError("Work-item queries require a tenant.")
|
||||||
|
if not 1 <= self.limit <= 500:
|
||||||
|
raise ValueError("Work-item query limits must be between 1 and 500.")
|
||||||
|
normalized = self.text.strip()
|
||||||
|
if len(normalized) > 500:
|
||||||
|
raise ValueError("Work-item query text is limited to 500 characters.")
|
||||||
|
if len(self.provider_ids) > 50 or len(self.owner_modules) > 50:
|
||||||
|
raise ValueError("Work-item queries support at most 50 provider filters.")
|
||||||
|
object.__setattr__(self, "text", normalized)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkItemPage:
|
||||||
|
items: tuple[WorkItem, ...]
|
||||||
|
total: int
|
||||||
|
truncated: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.total < len(self.items):
|
||||||
|
raise ValueError(
|
||||||
|
"Work-item totals cannot be smaller than the returned page."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class WorkItemProvider(Protocol):
|
||||||
|
def list_items(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: WorkItemQuery,
|
||||||
|
) -> WorkItemPage:
|
||||||
|
"""Return only items the current principal may discover and act on."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TaskCreateCommand:
|
||||||
|
tenant_id: str
|
||||||
|
title: str
|
||||||
|
idempotency_key: str
|
||||||
|
summary: str | None = None
|
||||||
|
priority: WorkItemPriority = "normal"
|
||||||
|
due_at: datetime | None = None
|
||||||
|
required_action: str | None = None
|
||||||
|
action_url: str | None = None
|
||||||
|
assignments: tuple[WorkAssignmentRef, ...] = ()
|
||||||
|
sources: tuple[WorkSourceRef, ...] = ()
|
||||||
|
provenance: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.tenant_id.strip() or not self.title.strip():
|
||||||
|
raise ValueError("Task commands require a tenant and title.")
|
||||||
|
if not self.idempotency_key.strip() or len(self.idempotency_key) > 255:
|
||||||
|
raise ValueError("Task commands require a bounded idempotency key.")
|
||||||
|
if not self.assignments:
|
||||||
|
raise ValueError("Explicit tasks require at least one assignment.")
|
||||||
|
if self.priority not in {"low", "normal", "high", "urgent"}:
|
||||||
|
raise ValueError(f"Unsupported task priority: {self.priority!r}.")
|
||||||
|
limits = {
|
||||||
|
"title": 500,
|
||||||
|
"summary": 4_000,
|
||||||
|
"required_action": 500,
|
||||||
|
"action_url": 1_500,
|
||||||
|
}
|
||||||
|
for field_name, limit in limits.items():
|
||||||
|
value = getattr(self, field_name)
|
||||||
|
if value is not None and len(value) > limit:
|
||||||
|
raise ValueError(
|
||||||
|
f"Task command {field_name} is limited to {limit} characters."
|
||||||
|
)
|
||||||
|
if len(self.assignments) > 100 or len(self.sources) > 100:
|
||||||
|
raise ValueError(
|
||||||
|
"Task commands support at most 100 assignments and sources."
|
||||||
|
)
|
||||||
|
_validate_action_url(self.action_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_action_url(value: str | None) -> None:
|
||||||
|
if value is None:
|
||||||
|
return
|
||||||
|
candidate = value.strip()
|
||||||
|
if not candidate:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
not candidate.startswith("/")
|
||||||
|
or candidate.startswith("//")
|
||||||
|
or "\\" in candidate
|
||||||
|
or any(ord(character) < 32 or ord(character) == 127 for character in candidate)
|
||||||
|
):
|
||||||
|
raise ValueError("Work-item action URLs must be application-relative paths.")
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class TaskCommandProvider(Protocol):
|
||||||
|
def create_task(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
command: TaskCreateCommand,
|
||||||
|
) -> WorkItem: ...
|
||||||
|
|
||||||
|
|
||||||
|
WorkItemProviderFactory = Callable[[ModuleContext], WorkItemProvider]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkItemProviderRegistration:
|
||||||
|
id: str
|
||||||
|
factory: WorkItemProviderFactory
|
||||||
|
order: int = 100
|
||||||
|
|
||||||
|
def create(self, context: ModuleContext) -> WorkItemProvider:
|
||||||
|
provider = self.factory(context)
|
||||||
|
if not isinstance(provider, WorkItemProvider):
|
||||||
|
raise TypeError(
|
||||||
|
f"Work-item provider {self.id!r} does not implement WorkItemProvider."
|
||||||
|
)
|
||||||
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegisteredWorkItemProvider:
|
||||||
|
module_id: str
|
||||||
|
registration: WorkItemProviderRegistration
|
||||||
|
|
||||||
|
|
||||||
|
def task_command_provider(registry: object | None) -> TaskCommandProvider | None:
|
||||||
|
if (
|
||||||
|
registry is None
|
||||||
|
or not hasattr(registry, "has_capability")
|
||||||
|
or not hasattr(registry, "capability")
|
||||||
|
or not registry.has_capability(CAPABILITY_TASK_COMMANDS)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
provider = registry.capability(CAPABILITY_TASK_COMMANDS)
|
||||||
|
return provider if isinstance(provider, TaskCommandProvider) else None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAPABILITY_TASK_COMMANDS",
|
||||||
|
"RegisteredWorkItemProvider",
|
||||||
|
"TaskCommandProvider",
|
||||||
|
"TaskCreateCommand",
|
||||||
|
"WORK_ITEM_CONTRACT_VERSION",
|
||||||
|
"WorkAssignmentKind",
|
||||||
|
"WorkAssignmentRef",
|
||||||
|
"WorkItem",
|
||||||
|
"WorkItemPage",
|
||||||
|
"WorkItemPriority",
|
||||||
|
"WorkItemProvider",
|
||||||
|
"WorkItemProviderFactory",
|
||||||
|
"WorkItemProviderRegistration",
|
||||||
|
"WorkItemQuery",
|
||||||
|
"WorkItemStatus",
|
||||||
|
"WorkSourceRef",
|
||||||
|
"task_command_provider",
|
||||||
|
]
|
||||||
@@ -107,7 +107,7 @@ class Settings(BaseSettings):
|
|||||||
default=(
|
default=(
|
||||||
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
|
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
|
||||||
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
|
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
|
||||||
"datasources,dataflow,dist_lists,templates,workflow_engine,workflow,views,search,risk_compliance,"
|
"datasources,dataflow,dist_lists,templates,workflow_engine,workflow,tasks,views,search,risk_compliance,"
|
||||||
"postbox,notifications,docs,ops"
|
"postbox,notifications,docs,ops"
|
||||||
),
|
),
|
||||||
alias="ENABLED_MODULES",
|
alias="ENABLED_MODULES",
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
|
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||||
|
from govoplan_core.core.tasks import (
|
||||||
|
WorkAssignmentRef,
|
||||||
|
WorkItem,
|
||||||
|
WorkItemPage,
|
||||||
|
WorkItemProviderRegistration,
|
||||||
|
WorkItemQuery,
|
||||||
|
WorkSourceRef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Provider:
|
||||||
|
def list_items(self, session, principal, *, query):
|
||||||
|
del session, principal
|
||||||
|
return WorkItemPage(
|
||||||
|
items=(
|
||||||
|
WorkItem(
|
||||||
|
id="task-1",
|
||||||
|
provider_id="example.items",
|
||||||
|
owner_module="example",
|
||||||
|
tenant_id=query.tenant_id,
|
||||||
|
title="Review request",
|
||||||
|
assignments=(WorkAssignmentRef(kind="account", id="account-1"),),
|
||||||
|
sources=(
|
||||||
|
WorkSourceRef(
|
||||||
|
module_id="cases",
|
||||||
|
resource_type="case",
|
||||||
|
resource_id="case-1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
due_at=datetime(2026, 8, 7, tzinfo=UTC),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
total=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkItemContractTests(unittest.TestCase):
|
||||||
|
def test_provider_registration_is_lazy_and_stable(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1",
|
||||||
|
work_item_providers=(
|
||||||
|
WorkItemProviderRegistration(
|
||||||
|
id="example.items",
|
||||||
|
factory=lambda _context: _Provider(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.validate()
|
||||||
|
registry.configure_capability_context(
|
||||||
|
ModuleContext(registry=registry, settings=object())
|
||||||
|
)
|
||||||
|
registered, provider = registry.work_item_providers()[0]
|
||||||
|
self.assertEqual("example.items", registered.registration.id)
|
||||||
|
page = provider.list_items(
|
||||||
|
object(),
|
||||||
|
object(),
|
||||||
|
query=WorkItemQuery(tenant_id="tenant-1"),
|
||||||
|
)
|
||||||
|
self.assertEqual("Review request", page.items[0].title)
|
||||||
|
|
||||||
|
def test_provider_ids_must_be_namespaced(self) -> None:
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="example",
|
||||||
|
name="Example",
|
||||||
|
version="1",
|
||||||
|
work_item_providers=(
|
||||||
|
WorkItemProviderRegistration(
|
||||||
|
id="items",
|
||||||
|
factory=lambda _context: _Provider(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(RegistryError, "namespaced"):
|
||||||
|
registry.validate()
|
||||||
|
|
||||||
|
def test_contract_rejects_unbounded_or_incomplete_values(self) -> None:
|
||||||
|
with self.assertRaisesRegex(ValueError, "tenant"):
|
||||||
|
WorkItemQuery(tenant_id="")
|
||||||
|
with self.assertRaisesRegex(ValueError, "assignment"):
|
||||||
|
WorkAssignmentRef(kind="account", id="")
|
||||||
|
with self.assertRaisesRegex(ValueError, "module"):
|
||||||
|
WorkSourceRef(module_id="", resource_type="case", resource_id="1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Generated
+21
@@ -41,6 +41,7 @@
|
|||||||
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||||
|
"@govoplan/tasks-webui": "file:../../govoplan-tasks/webui",
|
||||||
"@govoplan/templates-webui": "file:../../govoplan-templates/webui",
|
"@govoplan/templates-webui": "file:../../govoplan-templates/webui",
|
||||||
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
||||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||||
@@ -639,6 +640,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"../../govoplan-tasks/webui": {
|
||||||
|
"name": "@govoplan/tasks-webui",
|
||||||
|
"version": "0.1.18",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@govoplan/core-webui": "^0.1.18",
|
||||||
|
"lucide-react": "^1.23.0",
|
||||||
|
"react": ">=19.2.7 <20",
|
||||||
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
"react-router": ">=8.3.0 <9"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@govoplan/core-webui": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"../../govoplan-templates/webui": {
|
"../../govoplan-templates/webui": {
|
||||||
"name": "@govoplan/templates-webui",
|
"name": "@govoplan/templates-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.18",
|
||||||
@@ -1604,6 +1621,10 @@
|
|||||||
"resolved": "../../govoplan-search/webui",
|
"resolved": "../../govoplan-search/webui",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@govoplan/tasks-webui": {
|
||||||
|
"resolved": "../../govoplan-tasks/webui",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@govoplan/templates-webui": {
|
"node_modules/@govoplan/templates-webui": {
|
||||||
"resolved": "../../govoplan-templates/webui",
|
"resolved": "../../govoplan-templates/webui",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||||
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
|
||||||
"@govoplan/templates-webui": "file:../../govoplan-templates/webui",
|
"@govoplan/templates-webui": "file:../../govoplan-templates/webui",
|
||||||
|
"@govoplan/tasks-webui": "file:../../govoplan-tasks/webui",
|
||||||
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
"@govoplan/views-webui": "file:../../govoplan-views/webui",
|
||||||
"@govoplan/voting-webui": "file:../../govoplan-voting/webui",
|
"@govoplan/voting-webui": "file:../../govoplan-voting/webui",
|
||||||
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const packageByModule = {
|
|||||||
risk_compliance: "@govoplan/risk-compliance-webui",
|
risk_compliance: "@govoplan/risk-compliance-webui",
|
||||||
scheduling: "@govoplan/scheduling-webui",
|
scheduling: "@govoplan/scheduling-webui",
|
||||||
search: "@govoplan/search-webui",
|
search: "@govoplan/search-webui",
|
||||||
|
tasks: "@govoplan/tasks-webui",
|
||||||
tenancy: "@govoplan/tenancy-webui",
|
tenancy: "@govoplan/tenancy-webui",
|
||||||
templates: "@govoplan/templates-webui",
|
templates: "@govoplan/templates-webui",
|
||||||
views: "@govoplan/views-webui",
|
views: "@govoplan/views-webui",
|
||||||
@@ -93,8 +94,10 @@ const cases = [
|
|||||||
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
|
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||||
{ name: "approvals-only", modules: ["access", "approvals"] },
|
{ name: "approvals-only", modules: ["access", "approvals"] },
|
||||||
|
{ name: "tasks-only", modules: ["access", "tasks"] },
|
||||||
|
{ name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] },
|
||||||
{ name: "voting-only", modules: ["access", "voting"] },
|
{ name: "voting-only", modules: ["access", "voting"] },
|
||||||
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "records", "risk_compliance", "search", "voting"] }
|
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity_trust", "encryption", "cases", "committee", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "portal", "postbox", "projects", "reporting", "records", "risk_compliance", "search", "tasks", "voting"] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const npmExec = process.env.npm_execpath;
|
const npmExec = process.env.npm_execpath;
|
||||||
|
|||||||
Reference in New Issue
Block a user