Skip to content

v2: App-managed virtual sinks (ADR-0011) - #7

Merged
SimplyJustJessie merged 18 commits into
mainfrom
claude/v2-planning-7a5akz
Jul 19, 2026
Merged

v2: App-managed virtual sinks (ADR-0011)#7
SimplyJustJessie merged 18 commits into
mainfrom
claude/v2-planning-7a5akz

Conversation

@SimplyJustJessie

Copy link
Copy Markdown
Owner

Implements v2 per ADR-0011 (now accepted) and the new PLAN.v2.md build spec: AutoRoute owns the virtual-sink lifecycle — create/delete null sinks from the board, keep them alive across PipeWire restarts, recreate them at boot without AutoRoute running, and retire the static virtual-sinks.conf.

What's in here

  • Hybrid mechanism: declared sinks live in rules.json (schema v2, new virtualSinks array; v1 files load untouched and upgrade on next save). A generated pipewire-pulse.conf.d/autoroute-sinks.conf drop-in recreates sinks at boot; pactl load-module/unload-module gives instant runtime effect.
  • SinkReconciler runs before link reconcile on every pass: syncs the drop-in (write-if-changed, atomic), loads declared-but-missing sinks with a pactl list modules short guard (no double-creation racing the drop-in at boot), unloads only tagged stale modules, per-name backoff on failures.
  • Legacy migration: startup import of the static conf's sinks (tolerant parser), warn-only banner + journal until the user removes the file themselves.
  • UI: "+ New Sink" toolbar flyout (validated name, description, stereo/mono); VIRTUAL chip + delete button on managed columns; the delete confirm flyout lists affected rules/suppressions with an "also delete these" checkbox (default on) — sink + policy removed in one atomic save.
  • Gate verified live, 11/11 pass (scripts/v2-gate.sh --restart): prop tag round-trips through pw-dump, drop-in-booted modules are pactl-visible (load-path symmetry), pw-cli one-shot nodes die with the process, duplicate loads confirmed the modules-list guard is required. The committed fixtures are the live captures — which also exposed and fixed an importer bug (bare sink_properties=device.description='Music Sink' would have truncated descriptions to their first word).

Testing

  • 101 unit tests green (32 new: pactl driver, drop-in golden output, sink reconciler, importer, rules v2 round-trip, worker ordering); --smoke, --smoke-ui, --check-host, --screenshot all pass.
  • End-to-end on the real machine: follow docs/dev/v2-verification.md — Part A is a shell-executable runbook written for a local agent (declare/delete TestSink via rules.json hot-reload, PipeWire restart, drop-in-only survival, deletion convergence, with backup/cleanup guards); Part B is a ~3-minute human checklist for the flyout-only behaviours.

🤖 Generated with Claude Code

https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5


Generated by Claude Code

claude added 18 commits July 18, 2026 22:44
…normalization

RulesDocument gains a virtualSinks array (ADR-0011 declared sink set) and
CurrentVersion bumps to 2. A v1 file deserializes with the list null and is
normalized in memory (empty list, version bumped) so the next save writes v2.
SinkNameValidator centralizes the name/description restrictions that keep the
generated conf.d drop-in and pactl argv quoting-safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…roller)

Runtime sink lifecycle via pactl, mirroring PwLinker's posture: failures are
returned, never thrown. Loads stamp autoroute.managed=true into sink_properties;
list-modules parses 'pactl list modules short' tolerantly and only surfaces
module-null-sink rows with a parseable sink_name. Module indexes are treated as
ephemeral — identity is always sink_name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…le loop

SinkDropInWriter generates the pipewire-pulse drop-in from the declared set —
write-if-changed, atomic (AtomicFile extracted from RuleStore's pattern), empty
set deletes the file. SinkReconciler converges runtime state each pass: syncs
the drop-in, loads declared-but-absent sinks (guarded by a modules-list check so
the boot race with the drop-in can't double-create), unloads stale tagged
modules only, and backs off per-name on load failures. RoutingWorker runs the
sink pass before link reconcile; each half fails independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
PulseConfImporter scans pipewire-pulse.conf.d/*.conf (excluding our own
generated drop-in) for load-module module-null-sink entries with a tolerant
parser, and appends not-yet-declared sinks to rules.json in one save —
idempotent, skip-on-failure, never blocks startup. RoutingWorker runs it once
after loading rules; files still creating sinks statically surface through
AppNotices (UI banner) and as journal warnings, per the decision that retiring
the legacy file stays manual.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…cy banner

'+ New Sink' toolbar flyout (name validated + unique, description defaults to
name, stereo/mono) declares the sink and loads it instantly. Managed columns —
name membership in the declared set — get a VIRTUAL chip and a delete button
whose confirm flyout lists the rules/suppressions referencing the sink, with an
'also delete these' checkbox (default on) so sink + policy go in one atomic
save. Legacy static-conf files surface as an amber banner. IBoardCoordinator
gains CreateSinkAsync/PreviewDeleteSink/DeleteSinkAsync; harnesses get a
MockSinkController and the previewer declares GameSink. Also removes the stale
Wave-1 NotImplementedException catch in SafeReconcile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
scripts/v2-gate.sh verifies the pactl mechanics the hybrid design depends on
against the real PipeWire session (module index, node-prop round-trip, module
list shape, unload, drop-in boot with --restart, pw-cli negative, duplicate-name
behaviour) and captures live fixtures. ADR-0011 moves to accepted with the
settled fork (hybrid, pactl, rules.json v2, name-based ownership). PLAN.v2.md is
the v2 build spec mirroring PLAN.md's structure; contracts.md gains the frozen
v2 surfaces; CONTEXT.md gains Declared/Managed/Adopted sink vocabulary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Step 1-3, 5 pass; step 6 confirms duplicate load-module creates a second
same-named module, validating the reconciler's modules-list guard. The
autoroute.managed node prop round-trips through pw-dump as a JSON boolean,
matching the v1 link-tag behaviour. Step 4 (drop-in boot) pending a --restart
run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
… truncation

The full-gate run (--restart, 11/11 pass) is recorded in ADR-0011: the drop-in
boots the sink and boot-loaded modules are pactl-visible, closing the last
unverified assumption. The live pactl-modules capture replaces the hand-authored
sample (real multi-line libpipewire args, the user's four legacy sinks, the
tagged gate sink) and pw-dump.managed-sink.json is the real captured node.

The capture exposed a real bug: the legacy conf shape uses bare
sink_properties=device.description='Music Sink' (no outer double quotes), and
the importer tokenizer stripped the single quotes before the description regex
ran, truncating 'Music Sink' to 'Music'. The description is now extracted from
the raw args string, covering both the legacy shape and our generated drop-in
shape; the sample conf fixture now mirrors the real file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Part A is fully shell-executable against the live session — declaring and
deleting TestSink by editing rules.json exercises the same hot-reload +
SinkReconciler path the UI flyouts call — covering create, idempotence, the
flagship rule loop on a v2 sink, PipeWire restart without double-creation,
drop-in-only survival with AutoRoute stopped, and deletion convergence, with
backup/cleanup guards around the user's real rules.json. Part B is a short
human checklist for the flyout-only behaviours (validation, VIRTUAL chip,
delete-impact confirm, legacy banner).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…ire restart

End-to-end run A4 found that with the legacy virtual-sinks.conf still present
(warn-only retirement) and our drop-in declaring the same sinks, a
pipewire-pulse restart created every legacy sink twice — and the duplicate
node.name produced duplicate board-column keys, which threw in the ViewModel
diff-merge inside a dispatcher callback and took down the whole app (SIGABRT).

Prevention: the drop-in now excludes names any other conf file still declares
(PulseConfImporter.ScanExternalSinkNames, consumed by SinkReconciler), so boot
creation stays the legacy file's job until the user retires it. Cure: when a
declared name exists as multiple modules the reconciler keeps exactly one —
preferring the user's untagged copy — and unloads only tagged extras; duplicate
names in the graph snapshot trigger the cleanup pass, since restarts change the
graph, not the rules. Hardening: column keys are unique even for duplicate node
names, the diff-merge tolerates duplicates, and a failed board rebuild is
logged and skipped instead of aborting the process.

Runbook A0/A4/A5 now assert per-name module counts and app liveness (the checks
that would have caught this), and ADR-0011 records the field finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
The Create and Delete-sink flyout buttons did nothing: Button.OnClick raises
Click before invoking the bound Command, and the Click handler's synchronous
Flyout.Hide() tore down the popup DataContext, nulling the Command binding
before the invoke. The hide is now deferred via Dispatcher.Post, and the
headless UI smoke gains a regression check reproducing the exact ordering
(verified failing against the old code).

Column titles overflowed because the VIRTUAL-chip wrapper was a horizontal
StackPanel (infinite measure width defeats CharacterEllipsis); it's now a
left-aligned star Grid so titles trim, with the full name as a tooltip on
title and subtitle.

Legacy migration is now detect + OFFER: startup only detects what the static
conf declares (never writes rules.json) and the banner offers an 'Import into
AutoRoute' button — the sole path that imports. Deleting a sink now unloads
only tagged modules, so un-declaring a legacy-owned sink no longer kills the
user's live module. ADR-0011, contracts, and the verification runbook updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Detection only ran at startup, so un-declaring an imported legacy sink (or
importing pending ones) left the banner stale until a relaunch. The worker now
re-detects on every rules change — detection is a read-only scan that never
saves, so there's no feedback loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…mark

Avalonia Shape.Stretch=Uniform anchors a non-square geometry at the top-left of
a fixed square box, so the tall mic glyph sat off-center in its palette tile.
The tile glyphs now render through a centered Viewbox that scales the tight
geometry bounds, centering the short axis for every icon shape.

The window/taskbar + tray icon (Assets/tray-icon.png) and the packaging icon
(packaging/autoroute.png) are regenerated from the toolbar logo mark — rounded
indigo-gradient tile with the white wave — via the new scripts/gen-icon.py so
the asset stays reproducible from the design constants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
The stock Fluent scrollbar idled as a 2px rail and expanded into a gray band
with arrow buttons on hover — hard to grab and off-theme. The board's
ScrollViewer now sets AllowAutoHide=false so the bar is full-size by default,
with the arrow buttons hidden and a rounded slate thumb (hover lighten, accent
while dragging) on a transparent rail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Searching for a sink by name left its column visible but filtered out every
card whose own text didn't match — a matching column rendered empty (count
chip still populated) while links existed, reading as a data bug. A column
that matches the filter now shows everything feeding it; card-level filtering
applies only when the column itself isn't the match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Real-world device names (BurrBrown-length ALSA ids) painted past the palette
and column edges. Root cause: ScrollViewer.Padding sits outside the
scroll-content measure, so items were measured against a wider constraint than
the visible panel — Avalonia's StackPanel then arranges children at their
desired width, and trimmed-at-the-wrong-width text spilled out. The padding
moved onto the scroll content (the pattern BoardView's board scroller already
documented), matching measure to the panel.

Hardening: the design graph now carries a deliberately long-named capture
device wired into a column, and the headless smoke asserts no palette item,
card, or TextBlock paints past its panel — so this class of overflow fails CI
instead of shipping. Palette and card titles/subtitles get full-name tooltips,
the drag ghost caps its width, and the palette/column borders clip as a last
line of defense.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
The Unix file-mode calls (SetUnixFileMode, CreateDirectory with UnixFileMode)
warned about an unsupported Windows path — but AutoRoute drives PipeWire and
is Linux-only by nature. SupportedOSPlatform("linux") on every assembly tells
the platform analyzer those APIs are always supported here, which is the
semantic fix rather than a blanket rule suppression. Build is now 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Dragging a source into a sink and removing it spammed warn logs: routing is
deliberately multi-actor (optimistic UI plus reconciler passes on a possibly
stale snapshot), so the loser of each benign race got EEXIST on create ('File
exists') or ENOENT on delete ('No such file or directory'). Both mean the link
is already in the desired state — PwLinker now returns success and logs at
debug, keeping warnings for genuine failures (vanished ports etc.).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
@SimplyJustJessie
SimplyJustJessie merged commit 120a5d4 into main Jul 19, 2026
4 checks passed
SimplyJustJessie pushed a commit that referenced this pull request Aug 24, 2026
… and faster AppImage startup' (#7) from claude/spout2pw-video-routing into main

Reviewed-on: https://git.bussy.cloud/jessie/AutoRoute/pulls/7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants