Skip to content

Commit 9f83661

Browse files
committed
Fix: Preserve complete History edit cascades
Editing a complete History session could bypass the global in-place recompute planner, while multi-output or legacy actions could lose downstream dependencies after runtime mappings were pruned. All edit selections now use one deduplicated plan with consistent output UUID resolution. * [FIX] : Expand selected sessions into actions and process overlapping selections once through the global edit planner * [FIX] : Resolve dependency closure and failed-branch blocking from durable, runtime, then legacy output UUIDs * [CHG] : Cover Qt tree selection, pruned multi-output mappings, and legacy failed branches on Python 3.9 and 3.14
1 parent 0d2a98a commit 9f83661

3 files changed

Lines changed: 130 additions & 18 deletions

File tree

datalab/gui/panel/history/chain.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,19 @@ def action_output_uuid(panel: HistoryPanel, action: HistoryAction) -> str | None
219219
return find_output_object_uuid(panel, panel_data, action)
220220

221221

222+
def recorded_action_output_uuids(
223+
panel: HistoryPanel, action: HistoryAction
224+
) -> list[str]:
225+
"""Return output UUIDs recorded for ``action``, preferring durable history."""
226+
if action.output_uuids:
227+
return list(action.output_uuids)
228+
runtime_outputs = panel.runtime.objects.action_output_uuids.get(action.uuid, [])
229+
if runtime_outputs:
230+
return list(runtime_outputs)
231+
legacy_output = action_output_uuid(panel, action)
232+
return [legacy_output] if legacy_output is not None else []
233+
234+
222235
def action_consumes_any(action: HistoryAction, uuids: set[str]) -> bool:
223236
"""Return True if ``action``'s input UUIDs intersect ``uuids``."""
224237
if action.kind != HistoryAction.KIND_COMPUTE:
@@ -235,10 +248,10 @@ def get_downstream_actions(
235248
current = get_session_of(panel, action)
236249
if current is None:
237250
return []
238-
root_out = action_output_uuid(panel, action)
239-
if root_out is None:
251+
root_outputs = recorded_action_output_uuids(panel, action)
252+
if not root_outputs:
240253
return []
241-
closure: set[str] = {root_out}
254+
closure: set[str] = set(root_outputs)
242255
downstream: list[HistoryAction] = []
243256
idx = current.actions.index(action)
244257
for candidate in current.actions[idx + 1 :]:
@@ -247,9 +260,7 @@ def get_downstream_actions(
247260
if not action_consumes_any(candidate, closure):
248261
continue
249262
downstream.append(candidate)
250-
out_uuid = action_output_uuid(panel, candidate)
251-
if out_uuid is not None:
252-
closure.add(out_uuid)
263+
closure.update(recorded_action_output_uuids(panel, candidate))
253264
return downstream
254265

255266

datalab/gui/panel/history/interactive_replay.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,15 @@ def replay_restore_actions(
4343
if not panel.history_sessions:
4444
return
4545
selected = [panel.history_sessions[-1]]
46+
edit_mode = panel.runtime.execution.edit_mode
4647
edit_actions: list[HistoryAction] = []
4748
for session_or_action in selected:
48-
if isinstance(session_or_action, HistoryAction) and session_or_action.is_stale:
49+
if (
50+
replay
51+
and not edit_mode
52+
and isinstance(session_or_action, HistoryAction)
53+
and session_or_action.is_stale
54+
):
4955
hrec.recompute_cascade(panel, session_or_action)
5056
continue
5157
if not session_or_action.is_current_state_compatible(
@@ -59,22 +65,20 @@ def replay_restore_actions(
5965
)
6066
return
6167
if replay:
62-
if panel.runtime.execution.edit_mode and isinstance(
63-
session_or_action, HistoryAction
64-
):
65-
# Defer: edit only the selected actions, no automatic cascade
66-
edit_actions.append(session_or_action)
68+
if edit_mode:
69+
if isinstance(session_or_action, HistorySession):
70+
edit_actions.extend(session_or_action.actions)
71+
else:
72+
edit_actions.append(session_or_action)
6773
else:
68-
# Scope decision: clicking a session in edit mode now replays it
69-
# WITH parameter dialogs (view-only session replay disabled).
7074
with panel.replaying(), panel.output_suppressed():
7175
session_or_action.replay(
7276
panel.mainwindow,
7377
restore_selection=restore_selection,
74-
edit=panel.runtime.execution.edit_mode,
78+
edit=edit_mode,
7579
)
7680
elif restore_selection:
77-
if panel.runtime.execution.edit_mode or any(
81+
if edit_mode or any(
7882
action.has_pending_edits
7983
for session in panel.history_sessions
8084
for action in session.actions
@@ -196,15 +200,15 @@ def edit_mode_replay_actions(panel: HistoryPanel, actions: list[HistoryAction])
196200
continue
197201
if hchain.action_consumes_any(action, blocked_outputs):
198202
blocked_outputs.update(
199-
panel.runtime.objects.action_output_uuids.get(action.uuid, [])
203+
hchain.recorded_action_output_uuids(panel, action)
200204
)
201205
continue
202206
success = hrec.recompute_action_in_place(panel, action)
203207
action.is_stale = not success
204208
panel.tree.refresh_action_item(action)
205209
if not success:
206210
blocked_outputs.update(
207-
panel.runtime.objects.action_output_uuids.get(action.uuid, [])
211+
hchain.recorded_action_output_uuids(panel, action)
208212
)
209213
finally:
210214
hrec.flush_cascade_warnings(panel)

datalab/tests/features/common/history_workflow_test.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from datalab.adapters_metadata.common import ResultData
1818
from datalab.gui import historytools_ops as htools
1919
from datalab.gui.panel.history import HistoryAction
20+
from datalab.gui.panel.history import chain as hchain
2021
from datalab.gui.panel.history import interactive_replay as hireplay
2122
from datalab.gui.panel.history import recompute as hrec
2223
from datalab.gui.panel.history.chainmodel import (
@@ -36,6 +37,7 @@
3637
from datalab.tests.features.common.history_test_helpers import (
3738
add_paracetamol_signals,
3839
build_signal_chain,
40+
get_tree_item,
3941
read_history_sessions,
4042
select_tree_entry,
4143
select_tree_session,
@@ -493,6 +495,87 @@ def test_multi_action_edit_recomputes_selected_descendants_once() -> None:
493495
assert all(action.is_stale is False for action in expected)
494496

495497

498+
def test_edit_mode_selected_session_uses_global_replay_planner() -> None:
499+
"""Plan a selected session and duplicate stale action exactly once."""
500+
with datalab_test_app_context(history=True) as win:
501+
history, panel = win.historypanel, win.signalpanel
502+
history.toggle_record_mode(True)
503+
history.toggle_edit_mode(True)
504+
build_signal_chain(panel, history)
505+
session = history.history_sessions[-1]
506+
expected = list(session.actions)
507+
stale_action = expected[1]
508+
stale_action.is_stale = True
509+
select_tree_session(history, session)
510+
get_tree_item(history, stale_action.uuid).setSelected(True)
511+
selected = history.tree.get_selected_actions_or_sessions(
512+
history.history_sessions
513+
)
514+
assert selected == [session, stale_action]
515+
516+
with (
517+
patch.object(type(session), "replay") as direct_replay,
518+
patch.object(hrec, "recompute_cascade") as direct_cascade,
519+
patch.object(
520+
hireplay,
521+
"edit_mode_replay_actions",
522+
wraps=hireplay.edit_mode_replay_actions,
523+
) as edit_planner,
524+
patch.object(
525+
hireplay, "prompt_edit_action_params", return_value=True
526+
) as prompt,
527+
patch.object(
528+
hrec, "recompute_action_in_place", return_value=True
529+
) as recompute,
530+
):
531+
hireplay.replay_restore_actions(history)
532+
533+
direct_replay.assert_not_called()
534+
direct_cascade.assert_not_called()
535+
edit_planner.assert_called_once_with(history, [*expected, stale_action])
536+
assert [call.args[1] for call in prompt.call_args_list] == expected
537+
assert [call.args[1] for call in recompute.call_args_list] == expected
538+
assert all(action.is_stale is False for action in expected)
539+
540+
541+
def test_downstream_actions_follow_every_registered_output() -> None:
542+
"""Follow second registered outputs through transitive dependencies."""
543+
with datalab_test_app_context(history=True) as win:
544+
history, panel = win.historypanel, win.signalpanel
545+
history.toggle_record_mode(True)
546+
producer, consumer, descendant = build_signal_chain(panel, history).actions
547+
producer_second_output = "producer-second-output"
548+
consumer_second_output = "consumer-second-output"
549+
producer.output_uuids.append(producer_second_output)
550+
consumer.output_uuids.append(consumer_second_output)
551+
history.runtime.objects.action_output_uuids[producer.uuid] = list(
552+
producer.output_uuids
553+
)
554+
history.runtime.objects.action_output_uuids[consumer.uuid] = list(
555+
consumer.output_uuids
556+
)
557+
history.runtime.objects.output_to_action[producer_second_output] = producer.uuid
558+
history.runtime.objects.output_to_action[consumer_second_output] = consumer.uuid
559+
hchain.prune_output_mapping(history)
560+
assert producer_second_output in producer.output_uuids
561+
assert consumer_second_output in consumer.output_uuids
562+
assert (
563+
producer_second_output
564+
not in (history.runtime.objects.action_output_uuids[producer.uuid])
565+
)
566+
assert (
567+
consumer_second_output
568+
not in (history.runtime.objects.action_output_uuids[consumer.uuid])
569+
)
570+
consumer.state.selection["signal"] = [producer_second_output]
571+
descendant.state.selection["signal"] = [consumer_second_output]
572+
573+
assert hchain.get_downstream_actions(history, producer) == [
574+
consumer,
575+
descendant,
576+
]
577+
578+
496579
def test_multi_action_edit_cascades_across_independent_sessions() -> None:
497580
"""Recompute edited branches from multiple sessions in global order."""
498581
with datalab_test_app_context(history=True) as win:
@@ -527,6 +610,20 @@ def test_multi_action_edit_failure_skips_dependents_and_continues() -> None:
527610
history.create_new_session(panel_str="signal")
528611
successful_chain = build_independent_signal_branch(panel, history)
529612
failed_root = failed_chain[0]
613+
failed_output_uuid = failed_root.output_uuids[0]
614+
failed_root.output_uuids.clear()
615+
history.runtime.objects.action_output_uuids.pop(failed_root.uuid)
616+
history.runtime.objects.output_to_action.pop(failed_output_uuid)
617+
failed_output = panel.objmodel[failed_output_uuid]
618+
processing_parameters = extract_processing_parameters(failed_output)
619+
assert not failed_root.output_uuids
620+
assert failed_root.uuid not in history.runtime.objects.action_output_uuids
621+
assert failed_output_uuid not in history.runtime.objects.output_to_action
622+
assert processing_parameters is not None
623+
assert processing_parameters.func_name == failed_root.func_name
624+
assert hchain.recorded_action_output_uuids(history, failed_root) == [
625+
failed_output_uuid
626+
]
530627
recomputed: list[HistoryAction] = []
531628

532629
def recompute_action(_panel, action):

0 commit comments

Comments
 (0)