159 lines
5.3 KiB
Python
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"]
|