From c76fd2d7c50fb751dae6e0dbe0bfccf3dac46c9c Mon Sep 17 00:00:00 2001 From: TyperBody Date: Fri, 25 Sep 2026 20:02:17 +0800 Subject: [PATCH] fix(migration): treat an unselected runner id as not_legacy A saved pipeline may carry the post-migration shape {'ai': {'runner': {'id': ''}, 'runner_config': {}}} while no runner has been selected yet. The planner matched the empty id against the certified 'plugin:author/name/component' form, failed, and returned a malformed-id blocker, so the whole 'migrate all' batch stopped with both pipelines reported as blocked even though there was no legacy section and nothing to convert. A blank id with no legacy runner section now reports not_legacy: no work is needed and no target is synthesized. A blank id that still coexists with a legacy section remains an explicit invalid_runner_id blocker, because that ambiguous state must be resolved by the operator rather than guessed. --- .../pkg/pipeline/legacy_config_migration.py | 6 ++++ .../pipeline/test_legacy_config_migration.py | 30 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/langbot/pkg/pipeline/legacy_config_migration.py b/src/langbot/pkg/pipeline/legacy_config_migration.py index 8f99cce84..a3df33af0 100644 --- a/src/langbot/pkg/pipeline/legacy_config_migration.py +++ b/src/langbot/pkg/pipeline/legacy_config_migration.py @@ -634,6 +634,12 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict: return _block(result, 'mixed_runner_selection', 'ai.runner') if 'id' in selection: current = selection['id'] + # A blank runner id paired with no legacy runner section means the saved + # pipeline simply has no runner selected. There is nothing to migrate and + # nothing to convert, so report not_legacy instead of failing the whole + # batch with a malformed-id blocker. + if type(current) is str and not current.strip() and not any(legacy in ai for legacy in _TARGETS): + return result if type(current) is not str or not re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current): return _block(result, 'invalid_runner_id', 'ai.runner.id') result['state'] = 'already_current' diff --git a/tests/unit_tests/pipeline/test_legacy_config_migration.py b/tests/unit_tests/pipeline/test_legacy_config_migration.py index 7355400f1..d054b58ee 100644 --- a/tests/unit_tests/pipeline/test_legacy_config_migration.py +++ b/tests/unit_tests/pipeline/test_legacy_config_migration.py @@ -641,12 +641,40 @@ def test_dify_saved_timeout_is_not_blindly_activated(timeout): assert {'code': 'dify.timeout_default', 'field': 'ai.dify-service-api.timeout'} in result['warnings'] -@pytest.mark.parametrize('current', [None, '', False, [], 'plugin:bad', ' plugin:a/b/c', 'plugin:a/b/c/extra']) +@pytest.mark.parametrize('current', [None, False, [], 'plugin:bad', ' plugin:a/b/c', 'plugin:a/b/c/extra']) def test_malformed_current_id_is_not_already_current(current): source = {'ai': {'runner': {'id': current}}} assert_block(plan(source), 'invalid_runner_id', 'ai.runner.id') +@pytest.mark.parametrize('blank', ['', ' ']) +def test_blank_current_id_without_legacy_section_is_not_legacy(blank): + """A saved pipeline that simply has no runner selected is not legacy. + + It has no legacy runner section to convert and no target to synthesize, so it + must report not_legacy instead of blocking the whole batch with a + malformed-id diagnostic. + """ + source = {'ai': {'runner': {'id': blank, 'expire-time': 0}, 'runner_config': {}}} + result = plan(source) + assert result['state'] == 'not_legacy' + assert result['config'] is None + assert result['blockers'] == [] + assert result['changed_paths'] == [] + assert result['target_plugin'] is None + + +def test_blank_current_id_with_legacy_section_stays_blocked(): + """A blank id cannot silently coexist with a legacy section. + + An unselected plugin runner plus a legacy section is an ambiguous state that + the operator must resolve, so it keeps the malformed-id blocker. + """ + source = source_for() + source['ai']['runner'] = {'id': '', 'expire-time': 0} + assert_block(plan(source), 'invalid_runner_id', 'ai.runner.id') + + @pytest.mark.parametrize('runner', TARGETS) def test_deterministic_results_and_input_nonmutation_for_all_nine(runner): source = source_for(runner)