diff --git a/WingmanAiCore.spec b/WingmanAiCore.spec index 6683a438..8cdc10f5 100644 --- a/WingmanAiCore.spec +++ b/WingmanAiCore.spec @@ -287,6 +287,25 @@ try: except Exception as e: print(f"Warning: Could not collect faster_whisper: {e}") +# Config migration modules (services/migrations/migration_*.py) are discovered +# from the filesystem and imported via importlib at runtime, so static analysis +# never traces them — or anything only they import. 3.1.5 shipped without the +# stdlib module 'filecmp' (imported only by migration_313_to_314), which broke +# the 3.1.3 -> 3.1.5 upgrade chain in every packaged build while working fine +# from source. Feed every migration module to the analysis so its imports are +# bundled like normal code. +migration_hidden = sorted( + f"services.migrations.{mig_file[:-3]}" + for mig_file in os.listdir(os.path.join('services', 'migrations')) + if mig_file.startswith('migration_') and mig_file.endswith('.py') +) +if len(migration_hidden) < 14: + raise SystemExit( + f"Migration module enumeration looks incomplete ({len(migration_hidden)} found, " + "expected at least 14) — refusing to ship a bundle that cannot migrate user configs." + ) +hiddenimports += migration_hidden + # ============================================================================ # ANALYSIS # ============================================================================ @@ -304,6 +323,20 @@ a = Analysis( optimize=0, ) +# Verify the analyzed module graph contains every migration module and the one +# dependency that has already bitten us. A module missing here means the frozen +# build would fail to load a migration at runtime and break the upgrade chain. +pure_names = {entry[0] for entry in a.pure} +missing_migration_modules = [ + mod for mod in migration_hidden + ['filecmp'] if mod not in pure_names +] +if missing_migration_modules: + raise SystemExit( + "Migration modules/dependencies missing from the analyzed bundle: " + f"{', '.join(missing_migration_modules)} — refusing to ship a build " + "that cannot migrate user configs." + ) + # ============================================================================ # PACKAGING # ============================================================================ diff --git a/services/config_migration_service.py b/services/config_migration_service.py index 008aa0ca..c42a810a 100644 --- a/services/config_migration_service.py +++ b/services/config_migration_service.py @@ -60,6 +60,20 @@ def migrate_to_latest(self): # If we found an old version to migrate from, proceed with migration if start_version: + # Resolve the full migration chain BEFORE touching anything. + # A migration module that failed to load (e.g. a dependency + # missing from the packaged build, like filecmp in 3.1.5) must + # abort while all configs are still intact - never after + # deletions that count on the migration filling the gap. + migration_chain = self.build_migration_chain(start_version) + if migration_chain is None: + self.err( + f"No complete migration path from version {start_version.replace('_', '.')} " + f"to {self.latest_version.replace('_', '.')}. Migration aborted without " + "touching any configs; it will be retried on the next launch." + ) + return + self.log_highlight( f"Starting migration from version {start_version.replace('_', '.')} to {self.latest_version.replace('_', '.')}" ) @@ -86,17 +100,15 @@ def migrate_to_latest(self): # Only check 1.8.1 and 1.8.2 (most users come from 1.8.1, 1.8.2 was dev-only) self.migrate_audio_library() - # Perform migrations - current_version = start_version - while current_version != self.latest_version: - next_version = self.find_next_version(current_version) - if next_version is None: - self.err( - f"No migration path found from version {current_version} to {self.latest_version}. Migration aborted." - ) - break - self.perform_migration(current_version, next_version) - current_version = next_version + # Perform migrations along the pre-validated chain + for old_version, new_version in zip( + migration_chain, migration_chain[1:] + ): + try: + self.perform_migration(old_version, new_version) + except Exception: + self._cleanup_interrupted_step(new_version) + raise # Warn about custom skills that need manual review if custom_skills: @@ -145,6 +157,15 @@ def find_latest_migratable_version(self, users_dir): # Sort descending to get latest version first version_dirs.sort(key=lambda v: [int(n) for n in v.split("_")], reverse=True) + # Version dirs that are the target of a known migration step but never + # completed one (no .migration marker, written since 1.5.0) are partial + # artifacts of an interrupted or aborted migration - e.g. the + # template-only dir a broken update left behind. Never migrate FROM + # those while a completed version exists; the real data lives in an + # older dir. If they're all we have, use the newest one anyway. + migration_targets = {new for _, new, _ in self.migrations} + fallback = None + for version in version_dirs: # Skip the target version if version == self.latest_version: @@ -155,9 +176,22 @@ def find_latest_migratable_version(self, users_dir): f"Ignoring legacy version {version.replace('_', '.')} (older than minimum supported {MINIMUM_SUPPORTED_VERSION.replace('_', '.')})" ) continue + # Skip version dirs without configs - nothing to migrate from + if not path.exists(path.join(users_dir, version, CONFIGS_DIR)): + continue + if version in migration_targets and not path.exists( + path.join(users_dir, version, CONFIGS_DIR, MIGRATION_LOG) + ): + self.log_warning( + f"Version {version.replace('_', '.')} never completed a migration - " + "treating it as an interrupted-migration artifact, not as the migration source." + ) + if fallback is None: + fallback = version + continue return version - return None + return fallback def find_next_version(self, current_version): """Find the next version in the migration chain.""" @@ -166,6 +200,52 @@ def find_next_version(self, current_version): return new return None + def _cleanup_interrupted_step(self, new_version: str): + """Remove the partially-written configs of a crashed migration step. + + The step's target configs only contain data copied from the previous + version (still fully intact), so deleting them is safe. Leaving them + behind would make find_latest_migratable_version pick the partial + version as the migration source on the next launch, silently skipping + the crashed step's conversion. + """ + step_config_path = path.join(self.users_dir, new_version, CONFIGS_DIR) + if path.exists(step_config_path) and not path.exists( + path.join(step_config_path, MIGRATION_LOG) + ): + # An intermediate version dir is purely a product of the crashed + # step - remove it entirely so no empty shell survives. The latest + # version dir also holds templates/skills ConfigManager manages, + # so only its configs are removed (and restored on next launch). + if new_version == self.latest_version: + shutil.rmtree(step_config_path, ignore_errors=True) + else: + shutil.rmtree( + path.join(self.users_dir, new_version), ignore_errors=True + ) + self.err( + f"Migration step to {new_version.replace('_', '.')} was interrupted - " + "removed its partial configs so the next launch retries the step." + ) + + def build_migration_chain(self, start_version): + """Resolve the ordered list of versions from start_version to the latest. + + Returns None if any link is missing, e.g. because a migration module + failed to load. Callers must not perform any destructive operations + before checking this. + """ + chain = [start_version] + while chain[-1] != self.latest_version: + # a valid chain can't have more steps than there are migrations + if len(chain) > len(self.migrations): + return None + next_version = self.find_next_version(chain[-1]) + if next_version is None: + return None + chain.append(next_version) + return chain + def perform_migration(self, old_version, new_version): """Execute a single migration step.""" migration_class = next( @@ -741,6 +821,23 @@ def migrate( f"Old config found: {item} (normalized: {normalized})" ) + # Post-conversion (>= 3.1.4), deleted configs have no directory + # anymore - their tombstones live in context.yaml. Include them so + # a mid-chain rebuild doesn't resurrect deleted configs from + # templates. + old_context_file = path.join(old_config_path, CONTEXT_FILE) + if path.exists(old_context_file): + old_context = ( + self.config_manager.read_config(old_context_file) or {} + ) + for name in old_context.get("deleted_template_configs", []): + normalized = self.normalize_config_name(name) + if normalized: + old_config_normalized.add(normalized) + self.log( + f"Deleted config tombstone found: {name} - template will not be recreated" + ) + # Copy settings.yaml and defaults.yaml from old version # (they'll be transformed by migration callbacks) for config_file in ("settings.yaml", "defaults.yaml"): diff --git a/services/migrations/migration_315_to_316.py b/services/migrations/migration_315_to_316.py new file mode 100644 index 00000000..66cbc27e --- /dev/null +++ b/services/migrations/migration_315_to_316.py @@ -0,0 +1,19 @@ +"""Migration from version 3.1.5 to 3.1.6. + +No config changes. 3.1.6 is a migration hotfix release: packaged 3.1.5 builds +shipped without the stdlib module 'filecmp', so migration_313_to_314 failed to +load and the 3.1.3 -> 3.1.5 migration aborted after deleting the template +configs - leaving users with a template-only, marker-less 3_1_5 directory. +This release re-runs the chain from the last completed version (3_1_3 for +affected users, since marker-less interrupted artifacts are skipped as +migration source). +""" + +from services.migrations.base_migration import BaseMigration + + +class Migration315To316(BaseMigration): + """Migration from 3.1.5 to 3.1.6.""" + + old_version = "3_1_5" + new_version = "3_1_6" diff --git a/services/system_manager.py b/services/system_manager.py index 4de1afec..30b7684b 100644 --- a/services/system_manager.py +++ b/services/system_manager.py @@ -7,7 +7,7 @@ from api.enums import LogType from api.interface import SystemCore, SystemInfo -LOCAL_VERSION = "3.1.5" +LOCAL_VERSION = "3.1.6" class SystemManager: