Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions WingmanAiCore.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================================================
Expand All @@ -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
# ============================================================================
Expand Down
121 changes: 109 additions & 12 deletions services/config_migration_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('_', '.')}"
)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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."""
Expand All @@ -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(
Expand Down Expand Up @@ -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"):
Expand Down
19 changes: 19 additions & 0 deletions services/migrations/migration_315_to_316.py
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion services/system_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading