feat(migration): add Phase B matrix rewrite

- synthesize_sinks() now returns (sinks, hash_to_name) for matrix rewrite
- migrate_toggle_matrix() converts channel types to sink names, blanks rows below old min_severity
- migrate_all_matrices() processes all toggles with dry-run support
- apply_matrix_updates() writes updated toggles back to config

Tests added for matrix migration scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
zvx-echo6 2026-06-11 00:39:52 -06:00
commit d86b7d3515
2 changed files with 505 additions and 36 deletions

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Migration script for MeshAI routing simplification: synthesize sinks. """Migration script for MeshAI routing simplification: synthesize sinks + matrix rewrite.
This script reads existing notification toggles and rules, extracts their This script reads existing notification toggles and rules, extracts their
inline transport configurations, and synthesizes named sinks. inline transport configurations, and synthesizes named sinks.
@ -12,7 +12,10 @@ The migration:
3. For each enabled rule with inline transport config, synthesizes a named sink 3. For each enabled rule with inline transport config, synthesizes a named sink
4. Deduplicates identical transports into one sink 4. Deduplicates identical transports into one sink
5. Writes the sinks block to the config 5. Writes the sinks block to the config
6. Does NOT remove inline fields (done in a later step) 6. Rewrites severity_channels: channel types sink names (Phase B)
7. Blanks matrix rows below old min_severity threshold (Phase B)
8. Removes min_severity field from toggles (Phase B)
9. Does NOT remove inline transport fields (done in a later step)
Idempotent: refuses to run if a sinks block already exists. Idempotent: refuses to run if a sinks block already exists.
""" """
@ -163,14 +166,15 @@ def extract_sink_from_rule(rule: dict) -> Optional[dict]:
return None return None
def synthesize_sinks(notifications: dict) -> dict: def synthesize_sinks(notifications: dict) -> tuple[dict, dict]:
"""Synthesize named sinks from toggles and rules. """Synthesize named sinks from toggles and rules.
Returns: Returns:
dict mapping sink names to sink configs (sinks: dict mapping sink names to sink configs,
hash_to_name: dict mapping sink hashes to names for matrix migration)
""" """
sinks = {} sinks = {}
hash_to_name = {} # For deduplication hash_to_name = {} # For deduplication + matrix migration lookup
# Process toggles - extract ALL configured transports per toggle # Process toggles - extract ALL configured transports per toggle
toggles = notifications.get("toggles", {}) toggles = notifications.get("toggles", {})
@ -233,7 +237,161 @@ def synthesize_sinks(notifications: dict) -> dict:
rule_name = rule.get("name", f"rule-{i}") rule_name = rule.get("name", f"rule-{i}")
logger.info(f" Rule '{rule_name}' → sink '{sink_name}'") logger.info(f" Rule '{rule_name}' → sink '{sink_name}'")
return sinks return sinks, hash_to_name
# ---------- Phase B: Matrix rewrite ----------
SEVERITY_RANK = {"routine": 0, "priority": 1, "immediate": 2}
def build_channel_type_to_sink_map(toggle: dict, hash_to_name: dict) -> dict[str, str]:
"""Build a mapping from channel type to sink name for a toggle.
Uses the same hash-based lookup as synthesize_sinks to find which sink
was created from each transport type in this toggle.
Returns:
{"mesh_broadcast": "mesh-ch0", "mesh_dm": "dm-abc123", ...}
"""
mapping = {}
sink_dicts = extract_sinks_from_toggle(toggle)
for sink_dict in sink_dicts:
sink_type = sink_dict["type"]
sink_hash = compute_sink_hash(sink_dict)
if sink_hash in hash_to_name:
mapping[sink_type] = hash_to_name[sink_hash]
return mapping
def migrate_toggle_matrix(
toggle_name: str,
toggle: dict,
channel_to_sink: dict[str, str],
) -> tuple[dict, list[str]]:
"""Rewrite a toggle's severity_channels from channel types to sink names.
Also blanks matrix rows below old min_severity (they never fired anyway).
Args:
toggle_name: Name of this toggle (for logging)
toggle: The toggle dict
channel_to_sink: Mapping from channel type to sink name
Returns:
(new_severity_channels, list_of_changes)
"""
changes = []
old_matrix = toggle.get("severity_channels", {})
old_min_severity = toggle.get("min_severity", "routine")
min_rank = SEVERITY_RANK.get(old_min_severity, 0)
new_matrix = {}
for severity in ["routine", "priority", "immediate"]:
sev_rank = SEVERITY_RANK.get(severity, 0)
old_channels = old_matrix.get(severity, [])
# If this severity was below min_severity, blank the row
if sev_rank < min_rank:
if old_channels:
changes.append(f" {severity}: blanked (was below min_severity={old_min_severity})")
new_matrix[severity] = []
continue
# Convert channel types to sink names
new_sinks = []
for ch_type in old_channels:
# Skip digest pseudo-channel (it's a no-op)
if ch_type == "digest":
changes.append(f" {severity}: removed 'digest' (no-op pseudo-channel)")
continue
sink_name = channel_to_sink.get(ch_type)
if sink_name:
new_sinks.append(sink_name)
else:
# Channel type has no corresponding sink (shouldn't happen if
# synthesize_sinks ran first, but be defensive)
changes.append(f" {severity}: WARNING: no sink for channel type '{ch_type}'")
new_matrix[severity] = new_sinks
# Log the conversion
if old_channels != new_sinks:
changes.append(f" {severity}: {old_channels}{new_sinks}")
return new_matrix, changes
def migrate_all_matrices(
notifications: dict,
hash_to_name: dict,
) -> tuple[dict[str, dict], dict[str, list[str]]]:
"""Rewrite all toggle severity_channels matrices.
Returns:
(toggle_name -> new_severity_channels, toggle_name -> list_of_changes)
"""
new_matrices = {}
all_changes = {}
toggles = notifications.get("toggles", {})
for toggle_name, toggle in toggles.items():
if not isinstance(toggle, dict):
continue
channel_to_sink = build_channel_type_to_sink_map(toggle, hash_to_name)
if not channel_to_sink:
# Toggle has no inline transport config, skip matrix rewrite
continue
new_matrix, changes = migrate_toggle_matrix(toggle_name, toggle, channel_to_sink)
if changes:
new_matrices[toggle_name] = new_matrix
all_changes[toggle_name] = changes
return new_matrices, all_changes
def render_matrix_updates_yaml(
toggle_updates: dict[str, dict],
min_severity_removals: list[str],
layout: str,
) -> str:
"""Render YAML snippet showing matrix updates.
For dry-run display only - actual write uses yaml.safe_load/dump round-trip.
"""
lines = ["# Matrix updates:"]
for toggle_name, new_matrix in toggle_updates.items():
lines.append(f"toggles.{toggle_name}.severity_channels:")
for sev, sinks in new_matrix.items():
lines.append(f" {sev}: {sinks}")
if min_severity_removals:
lines.append("# min_severity removed from:")
for name in min_severity_removals:
lines.append(f" - {name}")
return "\n".join(lines)
def apply_matrix_updates(
config: dict,
layout: str,
toggle_updates: dict[str, dict],
) -> None:
"""Apply matrix updates to config dict in-place.
Also removes min_severity field from updated toggles.
"""
notifications = get_notifications_block(config, layout)
toggles = notifications.get("toggles", {})
for toggle_name, new_matrix in toggle_updates.items():
toggle = toggles.get(toggle_name)
if not isinstance(toggle, dict):
continue
toggle["severity_channels"] = new_matrix
# Remove min_severity - matrix is now the only gate
if "min_severity" in toggle:
del toggle["min_severity"]
def detect_config_layout(config: dict) -> str: def detect_config_layout(config: dict) -> str:
@ -428,9 +586,31 @@ def write_sinks_to_config(config_path: Path, sinks: dict, layout: str, backup_pa
return True return True
def write_full_config(config_path: Path, config: dict, layout: str, backup_path: Path) -> bool:
"""Write full config using yaml round-trip (for matrix updates).
Unlike write_sinks_to_config which preserves comments, this does a full
dump. Used when we need to modify fields in-place (matrix updates).
Returns:
True on success, False on failure (backup restored)
"""
import shutil
try:
with open(config_path, "w") as f:
yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
return True
except Exception as e:
logger.error(f"Failed to write config: {e}")
logger.info("Restoring from backup...")
shutil.copy2(backup_path, config_path)
return False
def main(): def main():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Migrate MeshAI config to use named sinks" description="Migrate MeshAI config to use named sinks + matrix rewrite"
) )
parser.add_argument( parser.add_argument(
"--config", "--config",
@ -443,6 +623,12 @@ def main():
action="store_true", action="store_true",
help="Show what would be done without making changes", help="Show what would be done without making changes",
) )
parser.add_argument(
"--phase",
choices=["a", "b", "all"],
default="all",
help="Phase A: sinks only; Phase B: matrix rewrite; all: both (default)",
)
args = parser.parse_args() args = parser.parse_args()
config_path = args.config config_path = args.config
@ -455,23 +641,55 @@ def main():
full_config, notifications, layout = load_notifications_config(config_path) full_config, notifications, layout = load_notifications_config(config_path)
logger.info(f"Detected config layout: {layout}") logger.info(f"Detected config layout: {layout}")
# Check if sinks already exist # ---------- Phase A: Synthesize sinks ----------
if notifications.get("sinks"): sinks = {}
logger.error("Sinks block already exists in config. Migration already complete.") hash_to_name = {}
logger.info("Existing sinks: %s", list(notifications["sinks"].keys()))
sys.exit(1)
# Synthesize sinks if args.phase in ("a", "all"):
logger.info("Synthesizing sinks from toggles and rules...") # Check if sinks already exist
sinks = synthesize_sinks(notifications) if notifications.get("sinks"):
if args.phase == "a":
logger.error("Sinks block already exists. Phase A already complete.")
sys.exit(1)
else:
# Phase "all" with existing sinks: skip Phase A, proceed to B
logger.info("Sinks already exist; skipping Phase A synthesis")
# Build hash_to_name from existing sinks for Phase B
existing_sinks = notifications.get("sinks", {})
for sink_name, sink_config in existing_sinks.items():
if isinstance(sink_config, dict):
sink_hash = compute_sink_hash(sink_config)
hash_to_name[sink_hash] = sink_name
sinks = existing_sinks
else:
logger.info("Synthesizing sinks from toggles and rules...")
sinks, hash_to_name = synthesize_sinks(notifications)
if not sinks: if not sinks:
logger.info("No sinks to synthesize (no inline transport configs found)") logger.info("No sinks to synthesize (no inline transport configs found)")
sys.exit(0) if args.phase == "a":
sys.exit(0)
else:
logger.info(f"Synthesized {len(sinks)} sink(s):")
for name, sink in sinks.items():
logger.info(f" {name}: {sink}")
logger.info(f"Synthesized {len(sinks)} sink(s):") # ---------- Phase B: Matrix rewrite ----------
for name, sink in sinks.items(): toggle_updates = {}
logger.info(f" {name}: {sink}") all_changes = {}
if args.phase in ("b", "all") and hash_to_name:
logger.info("Migrating severity_channels matrices...")
toggle_updates, all_changes = migrate_all_matrices(notifications, hash_to_name)
if toggle_updates:
logger.info(f"Matrix updates for {len(toggle_updates)} toggle(s):")
for toggle_name, changes in all_changes.items():
logger.info(f" {toggle_name}:")
for change in changes:
logger.info(f" {change}")
else:
logger.info("No matrix updates needed")
# Determine where sinks will land # Determine where sinks will land
if layout == "multifile": if layout == "multifile":
@ -479,24 +697,63 @@ def main():
else: else:
sinks_path = "notifications.sinks" sinks_path = "notifications.sinks"
# ---------- Dry-run output ----------
if args.dry_run: if args.dry_run:
logger.info("DRY RUN - no changes made") logger.info("DRY RUN - no changes made")
print(f"\n--- Config layout: {layout} ---") print(f"\n--- Config layout: {layout} ---")
print(f"--- Sinks will be written to: {sinks_path} ---")
print("\n--- Text to append: ---") if sinks and args.phase in ("a", "all") and not notifications.get("sinks"):
print(render_sinks_yaml(sinks, layout)) print(f"\n--- Phase A: Sinks will be written to: {sinks_path} ---")
print(render_sinks_yaml(sinks, layout))
if toggle_updates and args.phase in ("b", "all"):
print("\n--- Phase B: Matrix updates ---")
min_severity_removals = list(toggle_updates.keys())
print(render_matrix_updates_yaml(toggle_updates, min_severity_removals, layout))
return return
# Backup and write # ---------- Backup ----------
backup_path = backup_config(config_path) backup_path = backup_config(config_path)
logger.info(f"Backed up config to {backup_path}") logger.info(f"Backed up config to {backup_path}")
success = write_sinks_to_config(config_path, sinks, layout, backup_path) # ---------- Apply changes ----------
if not success: # For Phase A with no Phase B changes, use append strategy (preserves comments)
logger.error("Migration failed - config restored from backup") # For Phase B or combined, use yaml round-trip (loses comments but handles in-place edits)
sys.exit(1)
if args.phase == "a" and sinks and not notifications.get("sinks"):
# Phase A only: use append strategy
success = write_sinks_to_config(config_path, sinks, layout, backup_path)
if not success:
logger.error("Phase A migration failed - config restored from backup")
sys.exit(1)
logger.info(f"Wrote sinks block to {config_path} ({sinks_path})")
elif toggle_updates or (sinks and not notifications.get("sinks")):
# Phase B or combined: use yaml round-trip
# Re-parse the config to modify in-place
with open(config_path, "r") as f:
config_to_modify = yaml.safe_load(f) or {}
modify_layout = detect_config_layout(config_to_modify)
modify_notifications = get_notifications_block(config_to_modify, modify_layout)
# Add sinks if needed
if sinks and not modify_notifications.get("sinks"):
modify_notifications["sinks"] = sinks
logger.info(f"Added sinks block ({len(sinks)} sink(s))")
# Apply matrix updates
if toggle_updates:
apply_matrix_updates(config_to_modify, modify_layout, toggle_updates)
logger.info(f"Applied matrix updates to {len(toggle_updates)} toggle(s)")
# Write the modified config
success = write_full_config(config_path, config_to_modify, modify_layout, backup_path)
if not success:
logger.error("Migration failed - config restored from backup")
sys.exit(1)
logger.info(f"Wrote sinks block to {config_path} ({sinks_path})")
logger.info("Migration complete!") logger.info("Migration complete!")

View file

@ -425,7 +425,7 @@ class TestMigrationSynthesis:
} }
} }
sinks = synthesize_sinks(notifications) sinks, _ = synthesize_sinks(notifications)
# Should only have one sink for channel 2 # Should only have one sink for channel 2
assert len(sinks) == 1 assert len(sinks) == 1
@ -443,7 +443,7 @@ class TestMigrationSynthesis:
} }
} }
sinks = synthesize_sinks(notifications) sinks, _ = synthesize_sinks(notifications)
# Should have three unique sinks # Should have three unique sinks
assert len(sinks) == 3 assert len(sinks) == 3
@ -670,7 +670,7 @@ other_section:
_, notifications, layout = load_notifications_config(config_path) _, notifications, layout = load_notifications_config(config_path)
assert layout == "multifile" assert layout == "multifile"
sinks = synthesize_sinks(notifications) sinks, _ = synthesize_sinks(notifications)
rendered = render_sinks_yaml(sinks, layout) rendered = render_sinks_yaml(sinks, layout)
# The rendered YAML for multifile should not be indented # The rendered YAML for multifile should not be indented
@ -698,3 +698,215 @@ other_section:
# Mixed types # Mixed types
name = generate_sink_name("mesh_dm", {"node_ids": [987654321, "!xyz"]}) name = generate_sink_name("mesh_dm", {"node_ids": [987654321, "!xyz"]})
assert name == "dm-98765432" assert name == "dm-98765432"
class TestMatrixMigration:
"""Tests for Phase B: matrix rewrite migration."""
def test_build_channel_type_to_sink_map(self):
"""build_channel_type_to_sink_map correctly maps channel types to sink names."""
from meshai.scripts.migrate_config_routing import (
build_channel_type_to_sink_map,
compute_sink_hash,
)
# Build hash_to_name as synthesize_sinks would
hash_to_name = {
compute_sink_hash({"type": "mesh_broadcast", "channel": 2}): "mesh-ch2",
compute_sink_hash({"type": "mesh_dm", "node_ids": ["!abc123"]}): "dm-abc12345",
}
toggle = {
"broadcast_channel": 2,
"node_ids": ["!abc123"],
}
mapping = build_channel_type_to_sink_map(toggle, hash_to_name)
assert mapping["mesh_broadcast"] == "mesh-ch2"
assert mapping["mesh_dm"] == "dm-abc12345"
def test_migrate_toggle_matrix_converts_channel_types(self):
"""migrate_toggle_matrix converts channel types to sink names."""
from meshai.scripts.migrate_config_routing import migrate_toggle_matrix
toggle = {
"severity_channels": {
"routine": ["mesh_broadcast"],
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast", "mesh_dm"],
},
"min_severity": "routine",
}
channel_to_sink = {
"mesh_broadcast": "mesh-ch2",
"mesh_dm": "dm-ops",
}
new_matrix, changes = migrate_toggle_matrix("test", toggle, channel_to_sink)
assert new_matrix["routine"] == ["mesh-ch2"]
assert new_matrix["priority"] == ["mesh-ch2"]
assert new_matrix["immediate"] == ["mesh-ch2", "dm-ops"]
assert len(changes) > 0
def test_migrate_toggle_matrix_blanks_below_min_severity(self):
"""migrate_toggle_matrix blanks rows below min_severity threshold."""
from meshai.scripts.migrate_config_routing import migrate_toggle_matrix
toggle = {
"severity_channels": {
"routine": ["mesh_broadcast"],
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast"],
},
"min_severity": "priority", # routine should be blanked
}
channel_to_sink = {"mesh_broadcast": "mesh-ch0"}
new_matrix, changes = migrate_toggle_matrix("test", toggle, channel_to_sink)
assert new_matrix["routine"] == [], "routine should be blanked (below priority)"
assert new_matrix["priority"] == ["mesh-ch0"]
assert new_matrix["immediate"] == ["mesh-ch0"]
assert any("blanked" in c for c in changes)
def test_migrate_toggle_matrix_removes_digest(self):
"""migrate_toggle_matrix removes 'digest' pseudo-channel."""
from meshai.scripts.migrate_config_routing import migrate_toggle_matrix
toggle = {
"severity_channels": {
"routine": ["mesh_broadcast", "digest"],
"priority": ["mesh_broadcast", "digest"],
"immediate": ["mesh_broadcast"],
},
"min_severity": "routine",
}
channel_to_sink = {"mesh_broadcast": "mesh-ch0"}
new_matrix, changes = migrate_toggle_matrix("test", toggle, channel_to_sink)
# digest should be removed
assert "digest" not in new_matrix["routine"]
assert "digest" not in new_matrix["priority"]
assert any("digest" in c for c in changes)
def test_migrate_all_matrices_processes_all_toggles(self):
"""migrate_all_matrices processes all toggles with inline transport."""
from meshai.scripts.migrate_config_routing import (
migrate_all_matrices,
compute_sink_hash,
)
notifications = {
"toggles": {
"fire": {
"broadcast_channel": 1,
"severity_channels": {
"routine": [],
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast"],
},
"min_severity": "priority",
},
"weather": {
"broadcast_channel": 2,
"severity_channels": {
"routine": ["mesh_broadcast"],
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast"],
},
"min_severity": "routine",
},
}
}
hash_to_name = {
compute_sink_hash({"type": "mesh_broadcast", "channel": 1}): "mesh-ch1",
compute_sink_hash({"type": "mesh_broadcast", "channel": 2}): "mesh-ch2",
}
toggle_updates, all_changes = migrate_all_matrices(notifications, hash_to_name)
# Both toggles should have updates
assert "fire" in toggle_updates
assert "weather" in toggle_updates
# Fire's matrix should use mesh-ch1
assert toggle_updates["fire"]["priority"] == ["mesh-ch1"]
# Weather's matrix should use mesh-ch2
assert toggle_updates["weather"]["routine"] == ["mesh-ch2"]
def test_apply_matrix_updates_modifies_config(self):
"""apply_matrix_updates correctly modifies config in-place."""
from meshai.scripts.migrate_config_routing import apply_matrix_updates
config = {
"toggles": {
"fire": {
"enabled": True,
"min_severity": "priority",
"severity_channels": {
"routine": ["mesh_broadcast"],
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast"],
},
}
}
}
toggle_updates = {
"fire": {
"routine": [],
"priority": ["mesh-ch1"],
"immediate": ["mesh-ch1"],
}
}
apply_matrix_updates(config, "multifile", toggle_updates)
# Check matrix was updated
assert config["toggles"]["fire"]["severity_channels"]["routine"] == []
assert config["toggles"]["fire"]["severity_channels"]["priority"] == ["mesh-ch1"]
# Check min_severity was removed
assert "min_severity" not in config["toggles"]["fire"]
def test_full_migration_synthesis_and_matrix(self):
"""Full migration: synthesize sinks + rewrite matrices."""
from meshai.scripts.migrate_config_routing import (
synthesize_sinks,
migrate_all_matrices,
apply_matrix_updates,
)
notifications = {
"toggles": {
"fire": {
"enabled": True,
"broadcast_channel": 0,
"min_severity": "priority",
"severity_channels": {
"routine": ["mesh_broadcast"],
"priority": ["mesh_broadcast"],
"immediate": ["mesh_broadcast"],
},
},
}
}
# Phase A: synthesize sinks
sinks, hash_to_name = synthesize_sinks(notifications)
assert "mesh-ch0" in sinks
# Phase B: migrate matrices
toggle_updates, changes = migrate_all_matrices(notifications, hash_to_name)
assert "fire" in toggle_updates
# Routine should be blanked (below min_severity=priority)
assert toggle_updates["fire"]["routine"] == []
# Priority and immediate should have sink name
assert toggle_updates["fire"]["priority"] == ["mesh-ch0"]
assert toggle_updates["fire"]["immediate"] == ["mesh-ch0"]