feat(mutator): route mutate_decky through emit_decky_mutated with trigger

Mutator now emits one decky_mutated event (RFC 5424 + bus) per
successful mutation instead of the inline decky.<id>.state bus
publish.  The previous state topic published new_services only;
mutation events carry old/new/trigger, which is what the correlation
engine needs to interleave substrate-change markers into attacker
traversals.

- mutate_decky gains trigger: MutationTrigger = "operator" and
  captures old_services before the shuffle; replaces the inline
  _publish_safely(decky.<id>.state) with emit_decky_mutated(...).
- mutate_all derives trigger internally: operator when force or
  only-filter is set (CLI --all, API mutate-now, UI bus request);
  scheduled on interval ticks.  Passed through to each mutate_decky
  call.
- Tests updated: the old decky.<id>.state assertion is replaced
  with decky.<id>.mutation topic + mutation payload shape; 3 new
  tests cover trigger derivation for scheduled / force / only paths.
  26 tests in test_mutator.py green; 116 across mutator + topology
  + bus.
This commit is contained in:
2026-04-21 19:31:31 -04:00
parent f875350d75
commit fa0cdb3ab5
2 changed files with 75 additions and 16 deletions

View File

@@ -29,6 +29,7 @@ from decnet.bus.publish import (
publish_safely as _publish_safely,
run_health_heartbeat as _run_health_heartbeat,
)
from decnet.mutator.events import MutationTrigger, emit_decky_mutated
from decnet.web.db.repository import BaseRepository
log = get_logger("mutator")
@@ -40,6 +41,7 @@ async def mutate_decky(
decky_name: str,
repo: BaseRepository,
bus: BaseBus | None = None,
trigger: MutationTrigger = "operator",
) -> bool:
"""
Perform an Intra-Archetype Shuffle for a specific decky.
@@ -73,6 +75,7 @@ async def mutate_decky(
console.print(f"[yellow]No services available for mutating '{decky_name}'.[/]")
return False
old_services = list(decky.services)
current_services = set(decky.services)
attempts = 0
@@ -103,15 +106,12 @@ async def mutate_decky(
console.print(f"[red]Failed to mutate '{decky_name}': {e}[/]")
return False
await _publish_safely(
await emit_decky_mutated(
bus,
_topics.decky(decky_name, _topics.DECKY_STATE),
{
"name": decky_name,
"services": list(decky.services),
"last_mutated": decky.last_mutated,
},
event_type=_topics.DECKY_STATE,
decky=decky_name,
old_services=old_services,
new_services=list(decky.services),
trigger=trigger,
)
return True
@@ -143,6 +143,11 @@ async def mutate_all(
config = DecnetConfig(**state_dict["config"])
now = time.time()
# Trigger derivation: explicit force / targeted only-list come from
# an operator action (CLI --all, API mutate-now, UI bus request).
# Scheduled-interval ticks carry trigger=scheduled.
trigger: MutationTrigger = "operator" if (force or only is not None) else "scheduled"
mutated_count = 0
next_due_in: float | None = None
for decky in config.deckies:
@@ -162,7 +167,9 @@ async def mutate_all(
next_due_in = remaining
if due:
success = await mutate_decky(decky.name, repo=repo, bus=bus)
success = await mutate_decky(
decky.name, repo=repo, bus=bus, trigger=trigger,
)
if success:
mutated_count += 1