@@ -786,6 +786,7 @@ def _record_snapshot_internal(cls, label: str, timestamp: float, triggering_scop
786786 parameters = copy .deepcopy (state .parameters ),
787787 saved_parameters = copy .deepcopy (state ._saved_parameters ),
788788 provenance = copy .deepcopy (state ._live_provenance ),
789+ meta = copy .deepcopy (state .metadata ),
789790 )
790791
791792 # Determine parent_id for new snapshot
@@ -998,12 +999,41 @@ def time_travel_to_snapshot(cls, snapshot_id: str) -> bool:
998999 for pk , pv_before , pv_after in changed_param_keys :
9991000 logger .debug (f"⏱️ PARAM_CHANGE: { scope_key } param={ pk } before={ pv_before !r} after={ pv_after !r} " )
10001001
1002+ # Persist per-field before/after values for UI navigation.
1003+ # This allows downstream code (e.g., OpenHCS time-travel UI) to
1004+ # determine which dict-pattern key changed inside fields like 'func'.
1005+ state ._last_changed_values = {
1006+ pk : (pv_before , pv_after ) for pk , pv_before , pv_after in changed_param_keys
1007+ }
1008+
1009+ # Track UI / integration metadata changes for navigation.
1010+ prev_meta = copy .deepcopy (state .metadata )
1011+ next_meta = copy .deepcopy (state_snap .meta )
1012+ meta_changed_keys = set ()
1013+ meta_changed_values = {}
1014+ for mk in set (prev_meta .keys ()) | set (next_meta .keys ()):
1015+ before = prev_meta .get (mk )
1016+ after = next_meta .get (mk )
1017+ if before != after :
1018+ meta_changed_keys .add (mk )
1019+ meta_changed_values [mk ] = (before , after )
1020+ state ._last_changed_meta_keys = meta_changed_keys
1021+ state ._last_changed_meta_values = meta_changed_values
1022+ if meta_changed_keys :
1023+ scopes_with_changes .add (scope_key )
1024+ logger .debug (
1025+ "⏱️ META_CHANGE: %s keys=%s" ,
1026+ scope_key ,
1027+ sorted (meta_changed_keys ),
1028+ )
1029+
10011030 # RESTORE state (including saved_parameters for concrete dirty detection)
10021031 state ._saved_resolved = copy .deepcopy (state_snap .saved_resolved )
10031032 state ._live_resolved = copy .deepcopy (state_snap .live_resolved )
10041033 state ._live_provenance = copy .deepcopy (state_snap .provenance )
10051034 state .parameters = copy .deepcopy (state_snap .parameters )
10061035 state ._saved_parameters = copy .deepcopy (state_snap .saved_parameters )
1036+ state .metadata = copy .deepcopy (state_snap .meta )
10071037 # Back-compat: old snapshots may not include saved_parameters.
10081038 if state ._saved_parameters is None :
10091039 state ._saved_parameters = copy .deepcopy (state .parameters )
@@ -1350,6 +1380,7 @@ def import_history_from_dict(cls, data: Dict[str, Any]) -> None:
13501380 else state_data ['parameters' ]
13511381 ),
13521382 provenance = state_data ['provenance' ],
1383+ meta = state_data .get ('meta' ) or {},
13531384 )
13541385
13551386 snapshot = Snapshot (
@@ -1551,6 +1582,17 @@ def __init__(
15511582 self ._cached_object : Optional [Any ] = None # Cached result of to_object()
15521583 self ._cached_object_applied : bool = False # True if cached delegate was applied to object_instance
15531584
1585+ # UI / integration metadata (never participates in dirty detection)
1586+ self .metadata : Dict [str , Any ] = {}
1587+
1588+ # Time-travel navigation helpers (set by ObjectStateRegistry time travel)
1589+ # Maps param_name -> (before, after) for the last time-travel transition.
1590+ self ._last_changed_values : Dict [str , Tuple [Any , Any ]] = {}
1591+
1592+ # Maps metadata key -> (before, after) for the last time-travel transition.
1593+ self ._last_changed_meta_keys : Set [str ] = set ()
1594+ self ._last_changed_meta_values : Dict [str , Tuple [Any , Any ]] = {}
1595+
15541596 # Extract parameters using FLAT extraction (dotted paths)
15551597 # This replaces the old UnifiedParameterAnalyzer + _create_nested_states() approach
15561598 self .parameters : Dict [str , Any ] = {}
@@ -2174,16 +2216,87 @@ def get_saved_resolved_value(self, param_name: str) -> Any:
21742216 this returns the saved baseline with inheritance applied. This is useful for
21752217 compilation and other operations that should only consider saved state.
21762218
2219+ For container fields (dataclasses), this reconstructs the entire nested
2220+ dataclass with all sub-fields populated from saved resolved values.
2221+
21772222 Args:
21782223 param_name: Field name to resolve (can be dotted path like 'path_planning_config.well_filter')
21792224
21802225 Returns:
2181- Saved resolved value from _saved_resolved snapshot
2226+ Saved resolved value from _saved_resolved snapshot.
2227+ For dataclass fields, returns a reconstructed dataclass instance.
21822228 """
21832229 # Auto-detect delegate changes before resolving values
21842230 self ._check_and_sync_delegate ()
2231+
2232+ # Ensure saved resolved cache is populated
2233+ if not self ._saved_resolved :
2234+ self ._saved_resolved = self ._compute_resolved_snapshot (use_saved = True )
2235+
2236+ # Check if this is a container/dataclass field (has subfields in _saved_resolved)
2237+ prefix = f"{ param_name } ."
2238+ has_subfields = any (key .startswith (prefix ) for key in self ._saved_resolved .keys ())
2239+
2240+ if has_subfields :
2241+ # This is a container field - reconstruct the dataclass
2242+ field_type = self ._path_to_type .get (param_name )
2243+ if field_type is not None and is_dataclass (field_type ):
2244+ return self ._reconstruct_from_saved_resolved (param_name )
2245+
2246+ # Return the simple value (or None if not found)
21852247 return self ._saved_resolved .get (param_name )
21862248
2249+ def _reconstruct_from_saved_resolved (self , prefix : str ) -> Any :
2250+ """Recursively reconstruct dataclass from saved resolved values.
2251+
2252+ Similar to _reconstruct_from_prefix but uses _saved_resolved instead of parameters.
2253+
2254+ Args:
2255+ prefix: Current path prefix (e.g., 'analysis_consolidation_config')
2256+
2257+ Returns:
2258+ Reconstructed dataclass instance with resolved values
2259+ """
2260+ # Determine the type to reconstruct
2261+ if not prefix :
2262+ obj_type = type (self ._extraction_target )
2263+ else :
2264+ obj_type = self ._path_to_type .get (prefix )
2265+ if obj_type is None :
2266+ raise ValueError (f"No type mapping for prefix: { prefix } " )
2267+
2268+ prefix_dot = f'{ prefix } .' if prefix else ''
2269+
2270+ # Collect direct fields and nested prefixes from saved resolved values
2271+ direct_fields = {}
2272+ nested_prefixes = set ()
2273+
2274+ for path , value in self ._saved_resolved .items ():
2275+ if not path .startswith (prefix_dot ):
2276+ continue
2277+
2278+ remainder = path [len (prefix_dot ):]
2279+
2280+ if '.' in remainder :
2281+ # This is a nested field - collect the first component
2282+ first_component = remainder .split ('.' )[0 ]
2283+ nested_prefixes .add (first_component )
2284+ else :
2285+ # Direct field of this object
2286+ direct_fields [remainder ] = value
2287+
2288+ # Reconstruct nested dataclasses first
2289+ for nested_name in nested_prefixes :
2290+ nested_path = f'{ prefix_dot } { nested_name } '
2291+ nested_obj = self ._reconstruct_from_saved_resolved (nested_path )
2292+ direct_fields [nested_name ] = nested_obj
2293+
2294+ # Instantiate the dataclass with all resolved fields
2295+ # Note: We use the actual resolved values, not None placeholders
2296+ result = obj_type (** direct_fields )
2297+
2298+ return result
2299+
21872300 def get_provenance (self , param_name : str ) -> Optional [Tuple [str , type ]]:
21882301 """Get the source scope_id and type for an inherited field value.
21892302
@@ -2924,7 +3037,7 @@ def mark_saved(self) -> None:
29243037 else :
29253038 logger .warning (f"🔧 mark_saved: Descendant scope { descendant_scope !r} not found in registry!" )
29263039
2927- def restore_saved (self ) -> None :
3040+ def restore_saved (self , * , propagate_descendants : bool = True ) -> None :
29283041 """Restore parameters to the last saved baseline (from object_instance).
29293042
29303043 UNIFIED: Works for any object_instance type.
@@ -2941,12 +3054,17 @@ def restore_saved(self) -> None:
29413054 self ._sync_materialized_state ()
29423055 return
29433056
3057+ # If there are no unsaved edits, restoring is a semantic no-op and should not
3058+ # create time-travel snapshots (noise).
3059+ if not self .is_raw_dirty :
3060+ return
3061+
29443062 # Coalesce all restore side-effects into a single snapshot
29453063 with ObjectStateRegistry .atomic (f"restore { self .scope_id } " ):
2946- self ._restore_saved_impl ()
3064+ self ._restore_saved_impl (propagate_descendants = propagate_descendants )
29473065 return
29483066
2949- def _restore_saved_impl (self ) -> None :
3067+ def _restore_saved_impl (self , * , propagate_descendants : bool = True ) -> None :
29503068 """Internal restore implementation (wrapped by restore_saved atomic block)."""
29513069
29523070 # Find parameters that differ from saved baseline AND capture their container types
@@ -3007,17 +3125,24 @@ def _restore_saved_impl(self) -> None:
30073125 field_name = leaf_field_name
30083126 )
30093127
3010- # Propagate restore to descendant ObjectStates so their parameters reflect saved baseline
3011- # This ensures function ObjectStates are reset when their parent step is restored
3012- descendant_scopes = [
3013- scope
3014- for scope in ObjectStateRegistry ._states .keys ()
3015- if scope .startswith (f"{ self .scope_id } ::" )
3016- ]
3017- for descendant_scope in descendant_scopes :
3018- state = ObjectStateRegistry ._states .get (descendant_scope )
3019- if state :
3020- state ._restore_saved_impl ()
3128+ # Optionally propagate restore to descendant ObjectStates so their parameters reflect saved baseline.
3129+ #
3130+ # This is important when restoring a parent that *owns* descendant parameter state (e.g. Step -> function
3131+ # ObjectStates) so canceling the parent edit resets child ObjectStates.
3132+ #
3133+ # However, for delegation-based parents that act as context providers (e.g. orchestrator -> pipeline_config),
3134+ # restoring the parent should generally *not* restore descendant raw parameters (steps). Descendants should
3135+ # only have their resolved caches invalidated so they re-resolve against the restored context.
3136+ if propagate_descendants :
3137+ descendant_scopes = [
3138+ scope
3139+ for scope in ObjectStateRegistry ._states .keys ()
3140+ if scope .startswith (f"{ self .scope_id } ::" )
3141+ ]
3142+ for descendant_scope in descendant_scopes :
3143+ state = ObjectStateRegistry ._states .get (descendant_scope )
3144+ if state :
3145+ state ._restore_saved_impl (propagate_descendants = True )
30213146
30223147 # Emit on_resolved_changed for changed params so SAME-LEVEL observers flash
30233148 # (e.g., list item subscribed to this ObjectState sees the revert as a change)
0 commit comments