Skip to content

Commit aa74cd4

Browse files
committed
feat(objectstate): snapshot metadata for time travel
Persist ObjectState.metadata into history snapshots and track metadata diffs on time-travel so UIs can restore view state (e.g. selected dict-pattern key) and navigate consistently. Also adds restore controls for delegated configs and improves saved-resolved access for nested dataclasses.
1 parent bb9f696 commit aa74cd4

File tree

2 files changed

+143
-15
lines changed

2 files changed

+143
-15
lines changed

src/objectstate/object_state.py

Lines changed: 140 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

src/objectstate/snapshot_model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class StateSnapshot:
2929
parameters: Dict # Current concrete values
3030
saved_parameters: Dict # Concrete values at last save (for concrete dirty detection)
3131
provenance: Dict
32+
meta: Dict = field(default_factory=dict)
3233

3334

3435
@dataclass(frozen=True)
@@ -77,6 +78,7 @@ def to_dict(self) -> Dict:
7778
'parameters': ss.parameters,
7879
'saved_parameters': ss.saved_parameters,
7980
'provenance': ss.provenance,
81+
'meta': ss.meta,
8082
}
8183
for scope_id, ss in self.all_states.items()
8284
}
@@ -98,6 +100,7 @@ def from_dict(cls, data: Dict) -> 'Snapshot':
98100
else state_data['parameters']
99101
),
100102
provenance=state_data['provenance'],
103+
meta=state_data.get('meta') or {},
101104
)
102105
for scope_id, state_data in data['states'].items()
103106
}

0 commit comments

Comments
 (0)