# Copyright (c) 2025 Patrick Motsch """ Phase-5 Schicht-4 migration: convert raw ``featureInstanceId: ""`` 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": "", "featureCode": ""} 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"]