gateway/modules/workflows/automation2/featureInstanceRefMigration.py
2026-04-25 01:13:01 +02:00

159 lines
5.3 KiB
Python

# Copyright (c) 2025 Patrick Motsch
"""
Phase-5 Schicht-4 migration: convert raw ``featureInstanceId: "<uuid>"`` workflow
parameters into typed ``FeatureInstanceRef`` envelopes on disk.
Why
---
The Typed Action Architecture (see
``wiki/c-work/1-plan/2026-04-typed-action-architecture.md``) declares
``featureInstanceId`` as ``FeatureInstanceRef`` (a catalog-typed reference with
a ``featureCode`` discriminator). Older workflows persist this parameter as a
plain UUID string, which carries no type information and forces every action /
adapter to re-derive the feature code from the node type.
What this module does
---------------------
``materializeFeatureInstanceRefs(graph)`` walks every node, and whenever a
node parameter named ``featureInstanceId`` is a non-empty string (raw UUID),
it rewrites the value to a typed envelope::
{"$type": "FeatureInstanceRef",
"id": "<uuid>",
"featureCode": "<derived-from-node-method>"}
The runtime resolver (``graphUtils._unwrapTypedRef``) automatically unwraps
that envelope back to the canonical primitive (``id``) when feeding action
implementations, so legacy action code keeps working unchanged.
Idempotent
----------
Already-migrated values (already-envelope dicts, empty strings, ``None``) are
left untouched. Running the migration twice is a no-op.
Out of scope
------------
The runtime helper ``pickNotPushMigration.materializeConnectionRefs`` solves a
related but different problem (resolving empty ``connectionReference`` to
upstream DataRefs at run-start). Both helpers compose: the typical
``executeGraph`` pipeline is
raw graph
-> materializeFeatureInstanceRefs (this module, on save / on load)
-> materializeConnectionRefs (pickNotPushMigration, at run-start)
-> ActionNodeExecutor / ActionExecutor
"""
from __future__ import annotations
import copy
import logging
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Single source of truth for node-type → feature code mapping. Keep in sync
# with the method registry; values must be the same string the FeatureInstance
# row uses for its ``featureCode`` column.
_NODE_TYPE_PREFIX_TO_FEATURE_CODE: Dict[str, str] = {
"trustee": "trustee",
"redmine": "redmine",
"clickup": "clickup",
"sharepoint": "sharepoint",
"outlook": "outlook",
"email": "outlook",
"teamsbot": "teamsbot",
"ai": "ai",
}
def _deriveFeatureCode(nodeType: str) -> Optional[str]:
"""Best-effort feature-code derivation from a node type id.
Returns ``None`` if the prefix is not in the registry — the migration then
omits ``featureCode`` from the envelope rather than guessing wrongly.
"""
if not nodeType or not isinstance(nodeType, str):
return None
prefix = nodeType.split(".", 1)[0].strip().lower()
return _NODE_TYPE_PREFIX_TO_FEATURE_CODE.get(prefix)
def _isAlreadyTypedEnvelope(value: Any) -> bool:
return (
isinstance(value, dict)
and value.get("$type") == "FeatureInstanceRef"
and isinstance(value.get("id"), str)
)
def _isMigratableUuidValue(value: Any) -> bool:
"""A bare non-empty string is treated as a UUID candidate worth migrating.
We deliberately do NOT enforce a strict UUID regex — historically
workflows have been seen with non-UUID instance ids (e.g. demo seeds).
The migration converts whatever string is there; downstream code already
treats the value as opaque.
"""
return isinstance(value, str) and value.strip() != ""
def _buildEnvelope(uuidValue: str, nodeType: str) -> Dict[str, Any]:
envelope: Dict[str, Any] = {
"$type": "FeatureInstanceRef",
"id": uuidValue.strip(),
}
code = _deriveFeatureCode(nodeType)
if code:
envelope["featureCode"] = code
return envelope
def materializeFeatureInstanceRefs(graph: Dict[str, Any]) -> Dict[str, Any]:
"""Return a deep-copied graph with raw ``featureInstanceId`` strings rewritten
to typed ``FeatureInstanceRef`` envelopes.
The function never mutates its input. It is safe to call repeatedly
(idempotent) and on partial graphs (missing nodes, missing parameters).
"""
if not isinstance(graph, dict):
return graph
out = copy.deepcopy(graph)
nodes = out.get("nodes")
if not isinstance(nodes, list):
return out
migratedCount = 0
for node in nodes:
if not isinstance(node, dict):
continue
params = node.get("parameters")
if not isinstance(params, dict):
continue
current = params.get("featureInstanceId")
if current is None:
continue
if _isAlreadyTypedEnvelope(current):
continue
if not _isMigratableUuidValue(current):
continue
envelope = _buildEnvelope(current, node.get("type") or "")
params["featureInstanceId"] = envelope
migratedCount += 1
logger.debug(
"materializeFeatureInstanceRefs: node %s (%s) -> envelope %r",
node.get("id"),
node.get("type"),
envelope,
)
if migratedCount:
logger.info(
"materializeFeatureInstanceRefs: migrated %d featureInstanceId value(s)",
migratedCount,
)
return out
__all__ = ["materializeFeatureInstanceRefs"]