chore: dev-stack switches, shared e2e harness, and desktop shell rules

- `LIVE_RELOAD=false npm run dev` keeps the renderer alive across a machine
  suspend; the reload client otherwise destroys the session under test.
- `dev-peer.sh` plus a separate userdata dir runs a second local peer.
- `tools/voice-probe.js` samples peer state and RTP counters from a live
  window, persisting to localStorage so a renderer reload cannot erase it.
- e2e helpers for voice pairs, peer-role election, and a TURN relay.
- Electron single-instance and dev-client-load decisions move into rules
  files with colocated specs.
This commit is contained in:
2026-08-14 03:19:29 +02:00
parent d71e3a98da
commit e49b3ec112
41 changed files with 35947 additions and 50 deletions
+1
View File
@@ -58,6 +58,7 @@ Thumbs.db
# Environment & certs # Environment & certs
.env .env
.certs/ .certs/
.dev-userdata/
/server/data/variables.json /server/data/variables.json
/server/data/metoyou.sqlite /server/data/metoyou.sqlite
dist-server/* dist-server/*
+5 -13
View File
@@ -1,10 +1,6 @@
# Session Handoff # Session Handoff
> **New chat:** attach `@agents-docs/HANDOFF.md` (and only files under Changed files). Say: continue from this handoff; do not redo completed work. > **New chat:** attach `@agents-docs/HANDOFF.md`. Say: continue from this handoff; do not redo completed work.
>
> **Agents cannot open a new chat** — overwrite this file, then ask the user to start one.
>
> **Keep this file tiny:** always **overwrite** the whole file (never append). When the task is finished, **clear** back to `Status: none` and empty sections (see below).
**Status:** none **Status:** none
@@ -14,16 +10,12 @@
## Changed files ## Changed files
## Decisions (this session only) ## Decisions
## Failed approaches ## Failed approaches
## Current issue / blockers ## Current issue
## Next steps (exact) ## Next steps
## Commands to continue ## Commands
## Scope note
Default: `toju-app/` + targeted `electron/` + `.gitea/workflows/`. Out of scope unless listed here: `server/`, `e2e/`, `website/`, `docs-site/`.
+22 -1
View File
@@ -8,12 +8,21 @@ Tags help grepping: `rg '\\[attachments\\]' agents-docs/LESSONS-INDEX.md`
## Index ## Index
- A hold-on-unknown rule needs every attach site behind it — `[voice] [webrtc] [realtime]`
- Swap a live device with `replaceTrack`; an empty device list is missing evidence — `[voice] [webrtc] [devices]`
- One owner for a toggle the UI mirrors — `[voice] [state] [ui]`
- A timed-out sync round is not a clean one — `[messages] [realtime] [verification]`
- Derive a conversation id from canonical humans, never from the ids on the wire — `[direct-message] [identity]`
- Report whether a call event was delivered before showing a live call — `[direct-call] [verification]`
- Never spend a retry budget on attempts the transport cannot deliver — `[realtime] [recovery]`
- Repair a dead data channel on the live connection before rebuilding the peer — `[realtime] [webrtc] [recovery]`
- Compare peer ids only within one signal server's identity space — `[realtime] [identity] [webrtc]`
- Reproduce initiator/glare bugs with a simultaneous reconnect, not staggered joins — `[testing] [realtime] [webrtc]`
- Keep `NgOptimizedImage` off runtime blob and data URLs — `[angular] [images]` - Keep `NgOptimizedImage` off runtime blob and data URLs — `[angular] [images]`
- Read the exact Obsidian bug note for `fix bug "…"` (one note only) — `[workflow] [bugs]` - Read the exact Obsidian bug note for `fix bug "…"` (one note only) — `[workflow] [bugs]`
- Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file — `[i18n] [testing]` - Run `npm run i18n:sync` after editing any `public/i18n/catalog/*.json` file — `[i18n] [testing]`
- Match direct-call recipients against every local identity alias, exactly like DMs already do — `[direct-call] [identity]` - Match direct-call recipients against every local identity alias, exactly like DMs already do — `[direct-call] [identity]`
- Resolve outbound direct-call recipient ids to the peer's connected signal identity — `[direct-call] [identity] [signaling]` - Resolve outbound direct-call recipient ids to the peer's connected signal identity — `[direct-call] [identity] [signaling]`
- Canonicalize direct conversation ids across cross-signal actor aliases — `[direct-message] [identity]`
- Decide attachment receive admission once at request time; never re-gate size in the chunk handler — `[attachments]` - Decide attachment receive admission once at request time; never re-gate size in the chunk handler — `[attachments]`
- Re-queue attachment auto-downloads on every message/room binding event; never trust one transport's ordering — `[attachments] [realtime]` - Re-queue attachment auto-downloads on every message/room binding event; never trust one transport's ordering — `[attachments] [realtime]`
- Scope per-user UI state by user id, not by the client database — `[persistence] [multi-user] [custom-emoji]` - Scope per-user UI state by user id, not by the client database — `[persistence] [multi-user] [custom-emoji]`
@@ -57,4 +66,16 @@ Tags help grepping: `rg '\\[attachments\\]' agents-docs/LESSONS-INDEX.md`
- Prove the asked behavior; unit-green is not done — `[verification] [testing] [workflow]` - Prove the asked behavior; unit-green is not done — `[verification] [testing] [workflow]`
- Default to `toju-app/` + targeted `electron/` + CI; do not crawl the monorepo — `[workflow] [tokens] [scope]` - Default to `toju-app/` + targeted `electron/` + CI; do not crawl the monorepo — `[workflow] [tokens] [scope]`
- Write/overwrite HANDOFF.md for new chats; clear it when the task finishes — `[workflow] [tokens] [handoff]` - Write/overwrite HANDOFF.md for new chats; clear it when the task finishes — `[workflow] [tokens] [handoff]`
- An outage test that only re-checks the end state is not a guard — `[testing] [verification] [webrtc]`
- coturn hands out a relay candidate but refuses loopback peers by default — `[testing] [webrtc] [turn]`
- Pin a chosen media device with `deviceId: { exact }`, never a bare string — `[webrtc] [media] [electron]`
- A second dev Electron window needs its own `--user-data-dir``[electron] [dev-shell]`
- Never answer `second-instance` by relaunching the app — `[electron] [dev-shell]`
- `ERR_FAILED (-2)` on a dev `loadURL` usually means aborted, not unreachable — `[electron] [dev-shell]`
- Never gate a presence indicator on the observer's own participation — `[ui] [voice] [webrtc]`
- Never test suspend/resume against a live-reloading dev server — `[testing] [dev-shell] [verification]`
- Keep diagnostic history outside the page you are diagnosing — `[testing] [verification]`
- Record whether the user is in a call before calling zero RTP a failure — `[testing] [voice] [verification]`
- Assert continuity when the state you broke repairs itself — `[testing] [voice] [verification]`
- Missing gossip about a peer is not evidence it left voice — `[voice] [webrtc] [realtime]`
+154 -7
View File
@@ -25,6 +25,160 @@ Durable rules for AI agents working on this project.
## Lessons ## Lessons
### A hold-on-unknown rule needs every attach site behind it [voice] [webrtc] [realtime]
- **Trigger:** replacing a strict media gate with "hold an established path when nothing confirms the peer", while some fast path still attaches the track without asking the rule.
- **Rule:** route every attach site — including the pre-offer shortcut in `createPeerConnection` and every sibling media kind — through the same decision, and refresh all of them on each piece of new evidence.
- **Why:** an ungated attach becomes the guess the rule then protects: the track alone reads as an established path, so `hold` keeps sending indefinitely to a peer that never joined the channel. The strict gate used to erase that mistake on the next pass.
- **Example:** `MediaManager.mayOpenVoicePathToPeer()` gates the first offer in `create-peer-connection.ts`, `syncCameraRouting()` reuses `decideVoicePathRouting`, and `notePeerVoiceReport()` calls `refreshVoiceRouting()` so a departure report reaches the camera too, not just the mic.
### Missing gossip about a peer is not evidence it left voice [voice] [webrtc] [realtime]
- **Trigger:** gating an outgoing media track on the observer's store copy of the *remote* user's `voiceState`, and detaching whenever that copy is absent.
- **Rule:** close a negotiated media path only on positive evidence — we left voice, the peer itself reported leaving or another channel, or the connection is gone; treat absence as unknown and hold the path. Opening a path still requires confirmation, so a guess never starts sending the microphone.
- **Why:** the signal server broadcasts `user_left` for any socket it declares dead, so a suspend or a flaky hop wipes that copy while the peer is still in the channel. The observer then detached its mic permanently — a silent member with no way back through the UI, not even by toggling mute.
- **Example:** `decideVoicePathRouting()` in `toju-app/src/app/domains/voice-session/domain/logic/voice-path-routing.rules.ts`, used by `MediaManager.syncVoiceRouting()` for the mic and `mayHearPeerVoice()` for playback gain.
### Assert continuity when the state you broke repairs itself [testing] [voice] [verification]
- **Trigger:** proving a media cut by wiping a peer from the roster and then checking that audio still flows.
- **Rule:** when the broken state is refreshed by a periodic message, sample the victim second by second across the window instead of asserting an end state.
- **Why:** peers gossip their voice state every 5s (`VOICE_HEARTBEAT_INTERVAL_MS`), so the roster heals moments after the wipe and the mic re-attaches; the end-state check passed against the *unfixed* code and proved nothing. Only a per-second sample showed the peer losing audio.
- **Example:** `assertUninterruptedInboundAudio(peer, 10)` in `e2e/tests/voice/roster-loss-preserves-voice.spec.ts` fails on the first silent second; the earlier `assertTwoWayAudio` after the wipe did not.
### Never test suspend/resume against a live-reloading dev server [testing] [dev-shell] [verification]
- **Trigger:** suspending the machine to check whether a voice call survives sleep/wake, with the windows served by `ng serve`.
- **Rule:** disable the dev server's reload before any suspend/resume test (`LIVE_RELOAD=false npm run dev`), and treat a renderer reload in the results as an invalid run rather than a product finding.
- **Why:** `ng serve --ssl` runs Vite over HTTP/2; the suspend destroys that stream, so on resume Vite throws `The stream has been destroyed` from `viteTransformMiddleware` into the error overlay of every window, and its live-reload client reloads the page. The reload re-bootstraps the app out of the call, so the post-resume readings showed a "connected" peer with zero RTP — which looks exactly like a silently dead call but only meant the reloaded app was no longer in voice.
- **Example:** `dev.sh` appends `--live-reload=false` when `LIVE_RELOAD=false`; the first P7.4 attempt produced 20 `audio stalled` lines that proved nothing.
### Keep diagnostic history outside the page you are diagnosing [testing] [verification]
- **Trigger:** collecting samples into a `window.__probe` array in the DevTools console, then reading them back after the disruptive event.
- **Rule:** persist probe samples to `localStorage` (or outside the renderer entirely) and stamp each sample with a per-load id, so a reload keeps the history and becomes visible evidence instead of silent data loss.
- **Why:** the event under test is often the very thing that destroys in-heap state; a reload wiped every pre-suspend sample while leaving the old console lines on screen, so the probe looked loaded but `__voiceProbe` was undefined and the baseline was gone.
- **Example:** `tools/voice-probe.js` stores samples under `metoyou_voice_probe_v1` and reports `RENDERER RELOADED` when `performance.timeOrigin` changes between samples.
### Record whether the user is in a call before calling zero RTP a failure [testing] [voice] [verification]
- **Trigger:** asserting on inbound/outbound audio packets without also recording voice membership and local mic track state.
- **Rule:** capture `isVoiceConnected()` and the local audio tracks' `readyState` in the same sample as the RTP counters, and only call a stall a stall when the client is supposed to be in voice.
- **Why:** peer connections exist for chat data channels regardless of voice, so "connected with zero audio" is the normal reading outside a call; without the voice flag the two cases are indistinguishable and a healthy app looks broken.
- **Example:** `readLocalMedia()` in `tools/voice-probe.js` logs `in-voice mic=live`, and the stall check is gated on `current.voice === 'in-voice'`.
### Never answer `second-instance` by relaunching the app [electron] [dev-shell]
- **Trigger:** making a second dev launch reuse the open window by restarting the running instance (`app.relaunch(); app.exit(0)`).
- **Rule:** handle a second instance in place — focus and `webContents.reloadIgnoringCache()` — and never relaunch the process from the `second-instance` handler.
- **Why:** the relaunched successor inherits the same dev argument and asks for the single-instance lock while the dying parent still holds it, so it is refused as yet another second instance and the pair respawns forever; every generation also exits `0` instead of the launcher's handoff code, so `concurrently --kill-others` tears down `ng serve` and the API server, and an in-flight `loadURL` dies as `ERR_FAILED (-2)` that reads like an unreachable dev server.
- **Example:** `resolveSecondInstanceAction()` in `electron/app/second-instance.rules.ts` returns `'reload-existing'`, and `deep-links.ts` reloads instead of relaunching.
### Never gate a presence indicator on the observer's own participation [ui] [voice] [webrtc]
- **Trigger:** writing `if (!isUserInCurrentVoiceRoom(...)) return false` before reading a remote user's share/camera state.
- **Rule:** decide a remote indicator from the observed user's state alone; keep the observer's own session out of the input entirely.
- **Why:** a user sharing alone in a voice channel looked idle to everyone outside it, so nobody could tell there was anything to watch — while the peer plane had already delivered the announcement, because `screen-state` goes to every open data channel and not just voice participants.
- **Example:** `shouldShowStreamIndicator()` in `domains/voice-session/domain/logic/stream-indicator.rules.ts`; guarded by `e2e/tests/screen-share/outside-voice-live-indicator.spec.ts`, where the observer never joins voice.
### `ERR_FAILED (-2)` on a dev `loadURL` usually means aborted, not unreachable [electron] [dev-shell]
- **Trigger:** blaming the cert or `ng serve` when Electron logs `ERR_FAILED (-2) loading 'https://127.0.0.1:4200'`.
- **Rule:** read the rejection stack — `stopLoadingListener` means the navigation was stopped (window destroyed, app exiting), so look for whatever killed the process; `SSL=true` already appends `ignore-certificate-errors`.
- **Why:** the cert and the dev server were fine; the app was exiting underneath the load, and chasing TLS wasted the first pass at the bug.
- **Example:** `loadDevelopmentClientWithRetry()` in `electron/window/dev-client-load.rules.ts` retries and never throws, so the window still gets its listeners and shows a readable failure page.
### Pin a chosen media device with `deviceId: { exact }`, never a bare string [webrtc] [media] [electron]
- **Trigger:** the user picks a different microphone or camera and nothing changes — not mid-call, not after leaving and rejoining voice.
- **Rule:** build `getUserMedia` constraints as `deviceId: { exact: id }`, and handle `OverconstrainedError` / `NotFoundError` by retrying once with the system default.
- **Why:** a bare `deviceId: id` is an `ideal` constraint, so Chromium may satisfy it with the device it already had; the feature then looks broken while every unit test passes. `exact` makes the request fail loudly instead, which is why it needs the explicit fallback so an unplugged device degrades rather than killing the call.
- **Example:** `buildMicrophoneConstraints` in `audio-device-selection.rules.ts` plus the single retry with `SYSTEM_DEFAULT_AUDIO_DEVICE_ID` in `media.manager.ts` `captureMicrophone` and `direct-call.service.ts` `captureCallMicrophone`.
### A second dev Electron window needs its own `--user-data-dir` [electron] [dev-shell]
- **Trigger:** launching a second desktop instance for a two-user test; the existing window blinks and reloads and no second window appears.
- **Rule:** launch the peer with its own `--user-data-dir` (`npm run dev:peer`), and never launch the desktop shell from an agent shell.
- **Why:** Electron's single-instance lock is scoped to the `userData` directory, so a default-directory launch hands its argv to the running instance instead; `tools/launch-electron.js` always appends `--metoyou-dev-reload-existing`, and the `second-instance` handler in `electron/app/deep-links.ts` answers that with `app.relaunch(); app.exit(0)`. Separate data dirs are also what give the two windows separate identities.
- **Example:** `dev-peer.sh``--user-data-dir="$DIR/.dev-userdata/$PEER_NAME"`.
### An outage test that only re-checks the end state is not a guard [testing] [verification] [webrtc]
- **Trigger:** writing or trusting a test that breaks something (kills a server, closes a channel), then asserts the feature works again afterwards.
- **Rule:** also assert what must **not** have happened in between — for a call, that the `RTCPeerConnection` was never rebuilt (`countCreatedPeerConnections` unchanged) — and prove the assertion by temporarily injecting the regression.
- **Why:** re-checking only the end state passes for a client that tore the call down and rebuilt it, which the user hears as a dropped call. Injecting `peerManager.closeAllPeers()` on signaling reconnect kept every audio and peer-count assertion green; only the connection-count assertion failed.
- **Example:** `e2e/tests/voice/recovery-preserves-media.spec.ts` — "The call was never rebuilt behind the user back" compares counts captured before `testServer.kill()`.
### coturn hands out a relay candidate but refuses loopback peers by default [testing] [webrtc] [turn]
- **Trigger:** a relay-only test (`iceTransportPolicy: 'relay'`) where candidates gather fine but every peer connection ends up `closed`.
- **Rule:** run a local coturn with `--allow-loopback-peers` (plus `--log-file=stdout --verbose`, or `docker logs` stays empty and readiness cannot be observed).
- **Why:** without it coturn still allocates and Chrome still reports a `typ relay` candidate, so the failure looks like broken app code rather than a blocked relay; connectivity checks to the other 127.x browser are simply dropped.
- **Example:** `e2e/helpers/turn-server.ts``--allow-loopback-peers` next to `--relay-ip=127.0.0.1`.
### Swap a live device with `replaceTrack`; an empty device list is missing evidence [voice] [webrtc] [devices]
- **Trigger:** a settings picker changes a capture device (mic, camera) while a session is live, or code reacts to `devicechange` by re-reading `enumerateDevices()`.
- **Rule:** re-capture, then `replaceTrack` on the existing senders and stop the old track — never tear the session down and rejoin. Treat an empty (or id-less) device list as *no information*: only fall back to the system default when a populated list proves the saved id is gone. Ask for `deviceId` as a preference, not `exact`.
- **Why:** `voice-controls.component.ts` called `disconnect()` then `connect()` for a mic change, so every peer saw a leave/rejoin and the user lost the channel; the settings pickers wrote `localStorage` and applied nothing. `enumerateDevices()` returns `[]` before microphone permission is granted and Firefox never lists audio outputs, so "not in the list" would silently reset a valid choice on startup. A plain track swap on an already negotiated sender needs no SDP exchange, so the swap is invisible to peers.
- **Example:** `MediaManager.switchInputDevice()` + `resolveAudioDeviceSelection()` / `buildMicrophoneConstraints()` in `domains/voice-session/domain/logic/audio-device-selection.rules.ts`, owned by `VoiceAudioDeviceService`; proven by `e2e/tests/voice/live-input-device-change.spec.ts` (outbound audio keeps flowing, no rejoin broadcast).
### One owner for a toggle the UI mirrors [voice] [state] [ui]
- **Trigger:** two surfaces (in-channel controls and a settings modal, a tray and a window) each keep a local `signal` for the same boolean — mute, deafen, camera on.
- **Rule:** keep the state where the effect happens and let every surface read it back through a `computed`; never reset a mirror to a hardcoded value on teardown.
- **Why:** `MediaManager` owned `isMicMuted` / `isSelfDeafened`, but `voice-controls.component.ts` kept its own copies and reset them to `false` in `disconnect()`, so after leaving voice the button said unmuted while the track was still disabled — and playback was un-deafened behind the user's back.
- **Example:** `isMuted = computed(() => this.webrtcService.isMuted())` in `voice-controls.component.ts`; `disconnect()` passes the real state into `voicePlayback.updateDeafened()`.
### A timed-out sync round is not a clean one [messages] [realtime] [verification]
- **Trigger:** deciding a poll/backoff cadence (sync, presence, reconciliation) from a timeout firing with nothing received, or from a fire-and-forget send that "asked" every peer.
- **Rule:** model the round — who was actually reached, who replied, what they reported — and let only a fully answered round with nothing outstanding buy the slow cadence; re-arm the timer from the verdict of the round that just closed, never from the previous one.
- **Why:** `messages-sync.effects.ts` set `lastSyncClean = true` inside `syncTimeout$`, so a round nobody answered dropped the poll from 10s to 15min; `sendToPeer` also returned `void` and only logged when the channel was closed, so peers listed in `getConnectedPeers()` (filled at `connectionState === 'connected'`, before the data channel opens) counted as asked. On top of that, `repeat({ delay })` read the flag at emission time, so a round that discovered missing ids was already committed to a 15-minute wait.
- **Example:** `message-sync-round.rules.ts` (`createInventoryRound` / `recordInventoryReply` / `isInventoryRoundClean`) plus `messages-sync.effects.spec.ts`, which advances fake timers and asserts the fast cadence survives silence, a partial answer, an undelivered request, and a late reply reporting missing ids.
### Derive a conversation id from canonical humans, never from the ids on the wire [direct-message] [identity]
- **Trigger:** building or trusting a composite id (DM thread, call id, dedupe key) made of participant ids that arrived in a payload or came from a roster entry.
- **Rule:** resolve every id through an alias index first (`buildDirectParticipantAliasIndex``getCanonicalDirectConversationId` / `canonicalizeDirectConversationId`), and collapse already-stored alias copies on first touch instead of only fixing new ones.
- **Why:** `getDirectConversationId` sorted the raw pair, so a peer who addressed the local user by a provisioned foreign actor id produced a second thread; the recipient saw two conversations for one human and clicking the peer opened the empty one. Matching aliases for *admission* was already in place, which made the fork look like a delivery bug instead of an id bug.
- **Example:** `e2e/tests/chat/cross-signal-dm-identity.spec.ts` fails with `element(s) not found` for the peer's message the moment the self-alias group is dropped from `DirectMessageService.participantAliasIndex()`.
### Report whether a call event was delivered before showing a live call [direct-call] [verification]
- **Trigger:** calling a fire-and-forget send (`sendCallEvent`, broadcast, notify) and then moving the UI into the success state.
- **Rule:** return the transport result, ring before joining local media, and surface "reached nobody" through the same error signal the view already renders.
- **Why:** `startCall` joined voice first and dropped the boolean from `PeerDeliveryService.sendCallEvent`, so a call to an unreachable peer showed the caller in a live-looking session that would never connect.
- **Example:** `DirectCallService.ringParticipants` sets `deliveryError` (`call.errors.ringUndelivered`) and `private-call.component.ts` folds it into `callErrorMessage`; the e2e drives it with `window.simulateOffline()` on the caller.
### Never spend a retry budget on attempts the transport cannot deliver [realtime] [recovery]
- **Trigger:** writing or reviewing a bounded retry loop (peer reconnect, resync, delivery) that counts attempts before checking whether the channel it needs is even available.
- **Rule:** check the dependency first and defer without counting; spend an attempt only when it can actually reach the far side, and when the budget really does run out publish a state the UI can show and re-arm the loop when the dependency returns.
- **Why:** `peer-recovery.ts` incremented `reconnectAttempts` before `isSignalingConnected()`, so a ~60s signal outage burned all 12 attempts doing nothing, then cleared the timer and deleted the tracker entry with no user-visible state and no re-arm — the peer stayed dead until an unrelated roster event happened to heal it.
- **Example:** `schedulePeerReconnect` now defers while signaling is down, emits `peerRecoveryStatus$` `{ status: 'failed' }` at exhaustion, and `resumeStalledPeerRecovery()` re-arms from `handleSignalingConnectionStatus`.
### Repair a dead data channel on the live connection before rebuilding the peer [realtime] [webrtc] [recovery]
- **Trigger:** handling a closed/failed `RTCDataChannel` by tracking the peer as disconnected and rebuilding the whole `RTCPeerConnection`.
- **Rule:** while the connection is still `connected`, have the deterministically elected initiator create a replacement channel on that same connection (no renegotiation needed — the SCTP transport is already up) and let the other side adopt the incoming channel; rebuild only as the fallback when the replacement never opens.
- **Why:** the control channel dying took voice, camera, and screen share down with it, and `replaceDataChannel` was already implemented and wired but never called — the spec asserted `not.toHaveBeenCalled()` and the README described the soft replacement as if it shipped.
- **Example:** `e2e/tests/voice/recovery-preserves-media.spec.ts` asserts the created-`RTCPeerConnection` count stays at 1 per peer after `closeOpenDataChannels`; forcing the rebuild path makes it fail.
### Compare peer ids only within one signal server's identity space [realtime] [identity] [webrtc]
- **Trigger:** about to compare a remote `peerId` / roster `oderId` against a local id — deterministic initiator election, offer-collision politeness, reconnect election, self-filtering, or the `oderId` stamped into a voice/camera/screen payload.
- **Rule:** resolve the local id for that peer's signal server (`getLocalOderIdForSignalUrl` where the `signalUrl` is in hand, `getIdentifyCredentialsForPeer` inside the peer manager) and elect roles only through `peer-role.rules.ts`; never reach for the home credential.
- **Why:** one human has a different actor id per signal server, so a home-vs-foreign comparison is not antisymmetric — both peers offer (glare) or neither does until the 5s takeover, which is the "some users can't hear each other" report. It also makes your own foreign roster entry fail the self-check, so the client tries to peer with itself.
- **Example:** `realtime-session.service.ts` wired `getLocalOderId` to `getIdentifyCredentials()` (always home) while `shouldInitiatePeer` compared it against foreign roster ids.
### Reproduce initiator/glare bugs with a simultaneous reconnect, not staggered joins [testing] [realtime] [webrtc]
- **Trigger:** writing an e2e for peer election, glare, or "cannot hear each other" and joining clients one after another.
- **Rule:** get every client onto the roster, then reload/reconnect them with `Promise.all` so all pairs elect from the same snapshot, and assert real audio flow plus exactly one initiator per pair.
- **Why:** staggered joins let one side's 1s fallback-offer timer serialize negotiation, so a wrong comparison still converges and the test passes on broken code — three sequential-join runs passed against the known-bad wiring before the simultaneous reconnect made it fail on audio.
- **Example:** `e2e/tests/voice/cross-signal-initiator-election.spec.ts` — 4 users, 2 home signal servers, one shared voice channel, `Promise.all(reload)`.
### Interview before coding; dont guess the fix [workflow] [bugs] [tokens] ### Interview before coding; dont guess the fix [workflow] [bugs] [tokens]
- **Trigger:** about to edit product code for a bug/feature after reading the ask or Obsidian note, while acceptance, approach, or scope is still ambiguous or has real alternatives. - **Trigger:** about to edit product code for a bug/feature after reading the ask or Obsidian note, while acceptance, approach, or scope is still ambiguous or has real alternatives.
@@ -88,13 +242,6 @@ Durable rules for AI agents working on this project.
- **Why:** inbound and outbound identity bugs are independent; fixing admission on the callee does not help if the ring never leaves the caller or hits the wrong `targetUserId` on the wire. - **Why:** inbound and outbound identity bugs are independent; fixing admission on the callee does not help if the ring never leaves the caller or hits the wrong `targetUserId` on the wire.
- **Example:** `PeerDeliveryService.sendViaSignaling` + `DirectCallService.resolveRoutableRecipientId`; e2e `e2e/tests/voice/dm-header-call-ring.spec.ts` (callee-home room, people-search call). - **Example:** `PeerDeliveryService.sendViaSignaling` + `DirectCallService.resolveRoutableRecipientId`; e2e `e2e/tests/voice/dm-header-call-ring.spec.ts` (callee-home room, people-search call).
### Canonicalize direct conversation ids across cross-signal actor aliases [direct-message] [identity]
- **Trigger:** cross-signal DMs delivered to the callee but the caller never saw replies — User1 had two DM rows for User2, replies landed in the actor-id thread, and DM routes could throw "Cannot use direct messages without a current user" when `openConversation` ran before hydration.
- **Rule:** incoming DM traffic must resolve to the existing home-id conversation for the same peer (`direct-message-conversation-identity.rules.ts`), merge duplicate alias threads on load/receive, queue inbound events until `currentUser` hydrates, and guard route-driven `openConversation` calls when no owner id is available yet.
- **Why:** recipient-alias admission alone is insufficient — if `message.conversationId` still carries a foreign actor id, the client forks a second local thread and the original chat stays empty.
- **Example:** `resolveDirectConversationId` + `mergeAliasDirectConversations` in `DirectMessageService.handleIncomingMessage`; `appendCrossSignalActorAliases` + `pickDeliveryTargetIds` when a foreign-home recipient is addressed by home id but only their shared-room actor id is routed (debug pattern: `c84813bb…` home id vs `e79a95e5…` actor on `signal.toju.app`).
### Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments] ### Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments]
- **Trigger:** "Sending files between users doesn't really work" — a browser user clicked Request on a 1050 MB generic file, the request gate (`canReceiveAttachment`) admitted it for in-memory receive, the sender streamed chunks, but `handleFileChunk` still had a leftover hard `size > MAX_AUTO_SAVE_SIZE_BYTES` rejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender's `waitForAck` timed out, and the GUI never changed. - **Trigger:** "Sending files between users doesn't really work" — a browser user clicked Request on a 1050 MB generic file, the request gate (`canReceiveAttachment`) admitted it for in-memory receive, the sender streamed chunks, but `handleFileChunk` still had a leftover hard `size > MAX_AUTO_SAVE_SIZE_BYTES` rejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender's `waitForAck` timed out, and the GUI never changed.
Executable
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Launch one more Electron window against an already-running dev stack.
#
# Electron's single-instance lock is scoped to the userData directory, so a peer
# window only gets its own lock — and its own identity — when it gets its own
# --user-data-dir. Without that, tools/launch-electron.js hands its argv to the
# running instance and the dev-reload path just reloads window A instead.
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
PEER_NAME="${1:-peer}"
PEER_DATA_DIR="$DIR/.dev-userdata/$PEER_NAME"
if [ -f "$DIR/.env" ]; then
set -a
source "$DIR/.env"
set +a
fi
SSL="${SSL:-false}"
if [ "$SSL" = "true" ]; then
CLIENT_URL="https://127.0.0.1:4200"
export NODE_TLS_REJECT_UNAUTHORIZED=0
else
CLIENT_URL="http://127.0.0.1:4200"
fi
if ! npx wait-on --timeout 5000 "$CLIENT_URL" >/dev/null 2>&1; then
echo "No dev client at $CLIENT_URL — start the stack with 'npm run dev' first." >&2
exit 1
fi
mkdir -p "$PEER_DATA_DIR"
echo "Launching peer window '$PEER_NAME' (data dir: $PEER_DATA_DIR)"
echo "Reminder: a fresh peer needs https://localhost:3001 added under Settings -> signal servers."
exec npx cross-env NODE_ENV=development SSL="$SSL" node tools/launch-electron.js . \
--no-sandbox \
--disable-dev-shm-usage \
--user-data-dir="$PEER_DATA_DIR"
+14 -2
View File
@@ -12,6 +12,18 @@ if [ -f "$DIR/.env" ]; then
fi fi
SSL="${SSL:-false}" SSL="${SSL:-false}"
LIVE_RELOAD="${LIVE_RELOAD:-true}"
# Suspending the machine tears down the dev-server connection. The live-reload client
# answers the reconnect by reloading the page, which destroys the very session under
# test (voice call, peer connections, console state). Set LIVE_RELOAD=false to keep the
# renderer alive across a suspend. Editing client files then has no effect until restart.
NG_RELOAD_FLAG=""
if [ "$LIVE_RELOAD" != "true" ]; then
NG_RELOAD_FLAG=" --live-reload=false"
echo "Live reload disabled: client edits will NOT reach the running window."
fi
if [ "$SSL" = "true" ]; then if [ "$SSL" = "true" ]; then
# Ensure certs exist # Ensure certs exist
@@ -20,13 +32,13 @@ if [ "$SSL" = "true" ]; then
"$DIR/generate-cert.sh" "$DIR/generate-cert.sh"
fi fi
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key" NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key$NG_RELOAD_FLAG"
# Use 127.0.0.1 so wait-on does not hit a stale HTTP listener on localhost (::1). # Use 127.0.0.1 so wait-on does not hit a stale HTTP listener on localhost (::1).
WAIT_URL="https://127.0.0.1:4200" WAIT_URL="https://127.0.0.1:4200"
HEALTH_URL="https://127.0.0.1:3001/api/health" HEALTH_URL="https://127.0.0.1:3001/api/health"
export NODE_TLS_REJECT_UNAUTHORIZED=0 export NODE_TLS_REJECT_UNAUTHORIZED=0
else else
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0" NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0$NG_RELOAD_FLAG"
WAIT_URL="http://127.0.0.1:4200" WAIT_URL="http://127.0.0.1:4200"
HEALTH_URL="http://127.0.0.1:3001/api/health" HEALTH_URL="http://127.0.0.1:3001/api/health"
fi fi
+112
View File
@@ -0,0 +1,112 @@
import { expect, type Page } from '@playwright/test';
export interface PeerRoleEdge {
/** Remote peer id, in the identity space of the signal server routing that peer. */
peerId: string;
/** Our own actor id in that same identity space. */
localActorId: string | null;
isInitiator: boolean;
connectionState: string;
}
/**
* Read who elected themselves initiator for every active peer, together with the local
* actor id in that peer's identity space. Both halves of a pair must describe the same
* two ids, which is what makes the election comparable in the first place.
*/
export async function readPeerRoleEdges(page: Page): Promise<PeerRoleEdge[]> {
return await page.evaluate(() => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface PeerDataShape {
connection?: { connectionState?: string };
isInitiator?: boolean;
}
interface RealtimeShape {
peerManager?: { activePeerConnections?: Map<string, PeerDataShape> };
signalingTransportHandler?: {
getIdentifyCredentialsForPeer?: (peerId: string) => { oderId?: string } | null;
};
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return [];
}
const realtime = debugApi.getComponent(host)['realtime'] as RealtimeShape | undefined;
const peers = realtime?.peerManager?.activePeerConnections;
if (!peers) {
return [];
}
const edges: PeerRoleEdge[] = [];
peers.forEach((peerData, peerId) => {
const credentials = realtime?.signalingTransportHandler?.getIdentifyCredentialsForPeer?.(peerId);
edges.push({
connectionState: peerData.connection?.connectionState ?? 'unknown',
isInitiator: peerData.isInitiator === true,
localActorId: credentials?.oderId ?? null,
peerId
});
});
return edges;
});
}
/**
* How many RTCPeerConnections this page has created since load. A clean session creates
* exactly one per remote peer; a rebuilt peer - for example a non-initiator that gave up
* waiting for an offer that was never elected to be sent - adds another.
*/
export async function countCreatedPeerConnections(page: Page): Promise<number> {
return await page.evaluate(() =>
((window as unknown as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []).length
);
}
/**
* Every connected pair must have exactly one initiator. Comparing ids from two different
* identity spaces breaks the antisymmetry of the election, so both peers offer (glare) or
* neither does until a takeover timer fires.
*/
export function expectExactlyOneInitiatorPerPair(edgesByClient: Record<string, PeerRoleEdge[]>): void {
const directed = new Map<string, boolean>();
const pairs = new Set<string>();
for (const [clientName, edges] of Object.entries(edgesByClient)) {
for (const edge of edges) {
expect(
edge.localActorId,
`${clientName} has no local actor id in the identity space of peer ${edge.peerId}`
).toBeTruthy();
const localActorId = edge.localActorId as string;
directed.set(`${localActorId}->${edge.peerId}`, edge.isInitiator);
pairs.add([localActorId, edge.peerId].sort().join('<->'));
}
}
expect(pairs.size, 'expected at least one peer pair').toBeGreaterThan(0);
for (const pair of pairs) {
const [first, second] = pair.split('<->');
const forward = directed.get(`${first}->${second}`);
const backward = directed.get(`${second}->${first}`);
expect(forward, `missing peer connection ${first} -> ${second}`).not.toBeUndefined();
expect(backward, `missing peer connection ${second} -> ${first}`).not.toBeUndefined();
expect(
[forward, backward].filter(Boolean),
`expected exactly one initiator for pair ${pair}`
).toHaveLength(1);
}
}
+9 -1
View File
@@ -22,7 +22,11 @@ const SERVER_ENTRY = existsSync(SERVER_DIST_ENTRY) ? SERVER_DIST_ENTRY : SERVER_
const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY; const USE_COMPILED_SERVER = SERVER_ENTRY === SERVER_DIST_ENTRY;
// ── Create isolated temp data directory ────────────────────────────── // ── Create isolated temp data directory ──────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-')); // The Playwright helper supplies a durable directory when a test needs to
// restart the signaling process on the same port without losing its database.
const suppliedTmpDir = process.env.TEST_SERVER_DATA_DIR;
const ownsTmpDir = !suppliedTmpDir;
const tmpDir = suppliedTmpDir || mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
const dataDir = join(tmpDir, 'data'); const dataDir = join(tmpDir, 'data');
mkdirSync(dataDir, { recursive: true }); mkdirSync(dataDir, { recursive: true });
@@ -81,6 +85,10 @@ child.on('exit', (code) => {
// ── Cleanup on signals ─────────────────────────────────────────────── // ── Cleanup on signals ───────────────────────────────────────────────
function cleanup() { function cleanup() {
if (!ownsTmpDir) {
return;
}
try { try {
rmSync(tmpDir, { recursive: true, force: true }); rmSync(tmpDir, { recursive: true, force: true });
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`); console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
+85 -18
View File
@@ -1,11 +1,18 @@
import { spawn, type ChildProcess } from 'node:child_process'; import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events'; import { once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { createServer } from 'node:net'; import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
export interface TestServerHandle { export interface TestServerHandle {
port: number; port: number;
url: string; url: string;
restart: () => Promise<void>;
/** Kill the process but keep the port and data dir, so `start()` can bring it back. */
kill: () => Promise<void>;
/** Start the server again on the same port and data dir after `kill()`. */
start: () => Promise<void>;
stop: () => Promise<void>; stop: () => Promise<void>;
} }
@@ -15,27 +22,15 @@ const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
export async function startTestServer(retries = 3): Promise<TestServerHandle> { export async function startTestServer(retries = 3): Promise<TestServerHandle> {
for (let attempt = 1; attempt <= retries; attempt++) { for (let attempt = 1; attempt <= retries; attempt++) {
const port = await allocatePort(); const port = await allocatePort();
const child = spawn(process.execPath, [START_SERVER_SCRIPT], { const dataDir = await mkdtemp(join(tmpdir(), 'metoyou-e2e-handle-'));
cwd: E2E_DIR,
env: {
...process.env,
TEST_SERVER_PORT: String(port)
},
stdio: 'pipe'
});
child.stdout?.on('data', (chunk: Buffer | string) => { let child: ChildProcess | null = null;
process.stdout.write(chunk.toString()); let stopped = false;
});
child.stderr?.on('data', (chunk: Buffer | string) => {
process.stderr.write(chunk.toString());
});
try { try {
await waitForServerReady(port, child); child = await spawnTestServer(port, dataDir);
} catch (error) { } catch (error) {
await stopServer(child); await rm(dataDir, { recursive: true, force: true });
if (attempt < retries) { if (attempt < retries) {
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`); console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
@@ -48,8 +43,51 @@ export async function startTestServer(retries = 3): Promise<TestServerHandle> {
return { return {
port, port,
url: `http://localhost:${port}`, url: `http://localhost:${port}`,
restart: async () => {
if (stopped) {
throw new Error('Cannot restart a stopped test server');
}
if (child) {
await stopServer(child);
}
child = await spawnTestServer(port, dataDir);
},
kill: async () => {
if (stopped) {
throw new Error('Cannot kill a stopped test server');
}
if (child) {
await stopServer(child);
child = null;
}
},
start: async () => {
if (stopped) {
throw new Error('Cannot start a stopped test server');
}
if (child) {
return;
}
child = await spawnTestServer(port, dataDir);
},
stop: async () => { stop: async () => {
await stopServer(child); if (stopped) {
return;
}
stopped = true;
if (child) {
await stopServer(child);
child = null;
}
await rm(dataDir, { recursive: true, force: true });
} }
}; };
} }
@@ -57,6 +95,35 @@ export async function startTestServer(retries = 3): Promise<TestServerHandle> {
throw new Error('startTestServer: unreachable'); throw new Error('startTestServer: unreachable');
} }
async function spawnTestServer(port: number, dataDir: string): Promise<ChildProcess> {
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
cwd: E2E_DIR,
env: {
...process.env,
TEST_SERVER_DATA_DIR: dataDir,
TEST_SERVER_PORT: String(port)
},
stdio: 'pipe'
});
child.stdout?.on('data', (chunk: Buffer | string) => {
process.stdout.write(chunk.toString());
});
child.stderr?.on('data', (chunk: Buffer | string) => {
process.stderr.write(chunk.toString());
});
try {
await waitForServerReady(port, child);
} catch (error) {
await stopServer(child);
throw error;
}
return child;
}
async function allocatePort(): Promise<number> { async function allocatePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => { return await new Promise<number>((resolve, reject) => {
const probe = createServer(); const probe = createServer();
+188
View File
@@ -0,0 +1,188 @@
import { type BrowserContext, type Page } from '@playwright/test';
import type { WebRtcTestHarnessWindow } from './webrtc-test-window.types';
/** Same shape `IceServerSettingsService` persists under `metoyou_ice_servers`. */
interface StoredIceServerEntry {
id: string;
type: 'stun' | 'turn';
urls: string;
username?: string;
credential?: string;
}
export interface TurnCredentials {
urls: string;
username: string;
credential: string;
}
const ICE_SERVERS_STORAGE_KEY = 'metoyou_ice_servers';
/**
* Configure the app with a single TURN server, the way a user would in
* Settings -> ICE servers. Nothing test-specific reads this back: the app loads
* it through `IceServerSettingsService`, so the call really is configured the
* product way.
*
* Call BEFORE any `goto()`.
*/
export async function seedTurnOnlyIceServers(
target: BrowserContext | Page,
turn: TurnCredentials
): Promise<void> {
const entries: StoredIceServerEntry[] = [
{
credential: turn.credential,
id: 'e2e-turn',
type: 'turn',
urls: turn.urls,
username: turn.username
}
];
await target.addInitScript(
([key, value]) => {
localStorage.setItem(key, value);
},
[ICE_SERVERS_STORAGE_KEY, JSON.stringify(entries)] as const
);
}
/**
* Take away the direct path. Every `RTCPeerConnection` is built with
* `iceTransportPolicy: 'relay'`, so host and server-reflexive candidates are
* discarded and the call can only succeed by relaying through the configured
* TURN server - which is what a user behind symmetric NAT is forced to do.
*
* Install AFTER `installWebRTCTracking` (it wraps whatever constructor is
* current) and BEFORE any `goto()`.
*/
export async function forceRelayOnlyIce(target: BrowserContext | Page): Promise<void> {
await target.addInitScript(() => {
const harness = window as unknown as WebRtcTestHarnessWindow & {
__relayIceConfigs?: RTCConfiguration[];
};
const Wrapped = harness.RTCPeerConnection;
harness.__relayIceConfigs = [];
const RelayOnly = function(this: RTCPeerConnection, config?: RTCConfiguration) {
const relayConfig: RTCConfiguration = { ...config, iceTransportPolicy: 'relay' };
harness.__relayIceConfigs?.push(relayConfig);
return new Wrapped(relayConfig);
} as unknown as typeof RTCPeerConnection;
RelayOnly.prototype = Wrapped.prototype;
Object.setPrototypeOf(RelayOnly, Wrapped);
harness.RTCPeerConnection = RelayOnly;
});
}
/**
* The configuration each peer connection was actually built with. A relay-only
* run that connects nothing usually means the app handed over no TURN server at
* all, which looks identical to a broken relay from the outside.
*/
export async function getRelayIceConfigs(page: Page): Promise<RTCConfiguration[]> {
return await page.evaluate(() =>
(window as unknown as { __relayIceConfigs?: RTCConfiguration[] }).__relayIceConfigs ?? []
);
}
export interface SelectedCandidatePair {
localCandidateType: string;
remoteCandidateType: string;
}
/**
* The candidate pair each connection actually settled on. `relay` on the local
* side means our packets left through the TURN server rather than going direct.
*/
export async function getSelectedCandidatePairs(page: Page): Promise<SelectedCandidatePair[]> {
return await page.evaluate(async () => {
const connections = (window as unknown as WebRtcTestHarnessWindow).__rtcConnections ?? [];
const pairs: SelectedCandidatePair[] = [];
for (const pc of connections) {
let stats: RTCStatsReport;
try {
stats = await pc.getStats();
} catch {
continue;
}
const candidates = new Map<string, string>();
let selected: { localCandidateId?: string; remoteCandidateId?: string } | null = null;
stats.forEach((report) => {
if (report.type === 'local-candidate' || report.type === 'remote-candidate') {
candidates.set(report.id as string, (report as { candidateType?: string }).candidateType ?? 'unknown');
}
});
stats.forEach((report) => {
if (report.type !== 'candidate-pair') {
return;
}
const pair = report as unknown as {
state?: string;
nominated?: boolean;
selected?: boolean;
localCandidateId?: string;
remoteCandidateId?: string;
};
if (pair.state === 'succeeded' && (pair.nominated || pair.selected)) {
selected = pair;
}
});
if (!selected) {
continue;
}
const pair = selected as { localCandidateId?: string; remoteCandidateId?: string };
pairs.push({
localCandidateType: candidates.get(pair.localCandidateId ?? '') ?? 'unknown',
remoteCandidateType: candidates.get(pair.remoteCandidateId ?? '') ?? 'unknown'
});
}
return pairs;
});
}
/**
* Wait until `expectedPairs` connections report a settled candidate pair whose
* local candidate is a TURN relay.
*/
export async function waitForRelayedCandidatePairs(
page: Page,
expectedPairs: number,
timeoutMs = 60_000
): Promise<SelectedCandidatePair[]> {
const deadline = Date.now() + timeoutMs;
let latest: SelectedCandidatePair[] = [];
while (Date.now() < deadline) {
latest = await getSelectedCandidatePairs(page);
const relayed = latest.filter((pair) => pair.localCandidateType === 'relay');
if (relayed.length >= expectedPairs) {
return latest;
}
await page.waitForTimeout(1_000);
}
throw new Error(
`Timed out waiting for ${expectedPairs} relayed candidate pairs. Last seen: ${JSON.stringify(latest)}`
);
}
+137
View File
@@ -0,0 +1,137 @@
import { execFile } from 'node:child_process';
import { createServer } from 'node:net';
import { promisify } from 'node:util';
const run = promisify(execFile);
export interface TurnServerHandle {
urls: string;
username: string;
credential: string;
stop: () => Promise<void>;
}
const IMAGE = 'coturn/coturn:latest';
const CONTAINER_NAME = 'metoyou-e2e-turn';
const USERNAME = 'e2e';
const CREDENTIAL = 'e2epass';
const RELAY_MIN_PORT = 49_160;
const RELAY_MAX_PORT = 49_200;
/** Whether a working Docker daemon is reachable, so a spec can skip instead of failing. */
export async function isDockerAvailable(): Promise<boolean> {
try {
await run('docker', ['info'], { timeout: 15_000 });
return true;
} catch {
return false;
}
}
/**
* Run a throwaway coturn on the loopback interface. Relay-only tests need a real
* TURN server: `iceTransportPolicy: 'relay'` discards every other candidate, so
* without one there is no path at all and the test would prove nothing.
*/
export async function startTurnServer(): Promise<TurnServerHandle> {
await removeContainer();
const port = await allocatePort();
await run('docker', [
'run',
'--detach',
'--name',
CONTAINER_NAME,
'--network',
'host',
IMAGE,
'-n',
`--listening-port=${port}`,
'--listening-ip=127.0.0.1',
'--relay-ip=127.0.0.1',
`--min-port=${RELAY_MIN_PORT}`,
`--max-port=${RELAY_MAX_PORT}`,
'--lt-cred-mech',
`--user=${USERNAME}:${CREDENTIAL}`,
// Both browsers are on this machine. Without this coturn still hands out a
// relay candidate but refuses to forward to a 127.x peer, so ICE fails in a
// way that looks like a broken app rather than a blocked relay.
'--allow-loopback-peers',
'--realm=metoyou.test',
'--fingerprint',
'--no-tls',
'--no-dtls',
// Readiness is read back off `docker logs`: coturn logs to a file inside the
// container unless pointed at stdout, and the per-listener lines only appear
// at verbose level.
'--log-file=stdout',
'--verbose'
], { timeout: 120_000 });
await waitForTurnPort(port);
return {
credential: CREDENTIAL,
stop: removeContainer,
urls: `turn:127.0.0.1:${port}?transport=udp`,
username: USERNAME
};
}
async function removeContainer(): Promise<void> {
try {
await run('docker', [
'rm',
'--force',
CONTAINER_NAME
], { timeout: 30_000 });
} catch {
// No such container - nothing to clean up.
}
}
async function waitForTurnPort(port: number, timeoutMs = 20_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { stdout, stderr } = await run('docker', ['logs', CONTAINER_NAME], { timeout: 10_000 })
.catch(() => ({ stderr: '', stdout: '' }));
if (`${stdout}${stderr}`.includes(`UDP listener opened on: 127.0.0.1:${port}`)) {
return;
}
await delay(250);
}
throw new Error(`coturn did not open a UDP listener on 127.0.0.1:${port}`);
}
/** coturn binds this itself, so only probe for a free port and hand it over. */
async function allocatePort(): Promise<number> {
return await new Promise<number>((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
if (!address || typeof address === 'string') {
probe.close();
reject(new Error('Failed to resolve an ephemeral TURN port'));
return;
}
const { port } = address;
probe.close((error) => (error ? reject(error) : resolve(port)));
});
});
}
function delay(durationMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, durationMs);
});
}
+214
View File
@@ -0,0 +1,214 @@
import { expect, type Page } from '@playwright/test';
import type { Client } from '../fixtures/multi-client';
import { ChatRoomPage } from '../pages/chat-room.page';
import { RegisterPage } from '../pages/register.page';
import { ServerSearchPage } from '../pages/server-search.page';
import {
installAutoResumeAudioContext,
installWebRTCTracking,
waitForAudioStatsPresent,
waitForConnectedPeerCount,
waitForOpenDataChannelCount
} from './webrtc-helpers';
const PAIR_PASSWORD = 'TestPass123!';
export interface VoicePairClient extends Client {
displayName: string;
username: string;
}
/**
* Register two fresh users, put them in a new server, and connect both to one voice
* channel with the WebRTC tracking harness installed. Returns once both sides report a
* connected peer, an open data channel, and live audio stats.
*/
export async function createVoicePairInNewServer(
createClient: () => Promise<Client>,
serverName: string,
options: { channelName?: string; namePrefix?: string } = {}
): Promise<VoicePairClient[]> {
const channelName = options.channelName ?? 'General';
const namePrefix = options.namePrefix ?? 'Voice Pair';
const uniqueSuffix = Date.now();
const clients: VoicePairClient[] = [];
for (let index = 0; index < 2; index++) {
const client = await createClient();
await installDeterministicVoiceSettings(client.page);
await installWebRTCTracking(client.page);
await installAutoResumeAudioContext(client.page);
clients.push({
...client,
displayName: `${namePrefix} ${index + 1}`,
username: `voice_pair_${uniqueSuffix}_${index + 1}`
});
}
for (const client of clients) {
const registerPage = new RegisterPage(client.page);
await registerPage.goto();
await registerPage.register(client.username, client.displayName, PAIR_PASSWORD);
await expect(client.page).toHaveURL(/\/dashboard/, { timeout: 20_000 });
}
await new ServerSearchPage(clients[0].page).createServer(serverName, { description: `${namePrefix} voice session` });
await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ServerSearchPage(clients[1].page).joinServerFromSearch(serverName);
await expect(clients[1].page).toHaveURL(/\/room\//, { timeout: 20_000 });
await new ChatRoomPage(clients[0].page).ensureVoiceChannelExists(channelName);
for (const client of clients) {
const room = new ChatRoomPage(client.page);
await room.joinVoiceChannel(channelName);
await expect(room.voiceControls).toBeVisible({ timeout: 20_000 });
}
for (const client of clients) {
await waitForConnectedPeerCount(client.page, 1, 90_000);
await waitForOpenDataChannelCount(client.page, 1, 90_000);
await waitForAudioStatsPresent(client.page, 30_000);
}
return clients;
}
/** Pin voice settings so audio levels and codecs do not vary between runs. */
export async function installDeterministicVoiceSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('metoyou_voice_settings', JSON.stringify({
inputVolume: 100,
outputVolume: 100,
audioBitrate: 96,
latencyProfile: 'balanced',
includeSystemAudio: false,
noiseReduction: false,
screenShareQuality: 'balanced',
askScreenShareQuality: false
}));
});
}
export async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
const searchInput = page.getByPlaceholder('Search servers...');
await expect(searchInput).toBeVisible({ timeout: 20_000 });
await searchInput.fill(roomName);
const roomCard = page.locator('div[title]', { hasText: roomName }).first();
await expect(roomCard).toBeVisible({ timeout: 20_000 });
await roomCard.dblclick();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
export async function openSavedRoomByName(page: Page, roomName: string): Promise<void> {
const roomButton = page.locator(`button[title="${roomName}"]`);
await expect(roomButton).toBeVisible({ timeout: 20_000 });
await roomButton.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
await waitForCurrentRoomName(page, roomName);
}
export async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
await page.waitForFunction(
(expectedRoomName) => {
interface RoomShape { name?: string }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
return currentRoom?.name === expectedRoomName;
},
roomName,
{ timeout }
);
}
export async function joinVoiceChannelUntilConnected(
page: Page,
channelName: string,
attempts = 3
): Promise<void> {
const room = new ChatRoomPage(page);
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
await room.joinVoiceChannel(channelName);
try {
await waitForLocalVoiceChannelConnection(page, channelName, 20_000);
await expect(room.muteButton).toBeVisible({ timeout: 10_000 });
return;
} catch (error) {
lastError = error;
await page.waitForTimeout(1_000);
}
}
const lastErrorMessage = lastError instanceof Error
? `Last error: ${lastError.message}`
: 'Last error: unavailable';
throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`);
}
export async function waitForLocalVoiceChannelConnection(
page: Page,
channelName: string,
timeout = 20_000
): Promise<void> {
await page.waitForFunction(
(name) => {
interface VoiceStateShape { isConnected?: boolean; roomId?: string; serverId?: string }
interface UserShape { voiceState?: VoiceStateShape }
interface ChannelShape { id: string; name: string; type: 'text' | 'voice' }
interface RoomShape { id: string; channels?: ChannelShape[] }
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return false;
}
const component = debugApi.getComponent(host);
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
const currentUser = (component['currentUser'] as (() => UserShape | null) | undefined)?.() ?? null;
const voiceChannel = currentRoom?.channels?.find((ch) => ch.type === 'voice' && ch.name === name);
const voiceState = currentUser?.voiceState;
return !!voiceChannel
&& voiceState?.isConnected === true
&& voiceState.roomId === voiceChannel.id
&& voiceState.serverId === currentRoom.id;
},
channelName,
{ timeout }
);
}
+28 -7
View File
@@ -1,11 +1,11 @@
import { app } from 'electron'; import { app } from 'electron';
import * as path from 'path'; import * as path from 'path';
import { createWindow, getMainWindow } from '../window/create-window'; import { createWindow, getMainWindow } from '../window/create-window';
import { resolveSecondInstanceAction } from './second-instance.rules';
const CUSTOM_PROTOCOL = 'toju'; const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`; const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE'; const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
let pendingDeepLink: string | null = null; let pendingDeepLink: string | null = null;
@@ -42,6 +42,24 @@ function focusMainWindow(): void {
mainWindow.focus(); mainWindow.focus();
} }
function reloadMainWindow(): void {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
void createWindow();
return;
}
focusMainWindow();
if (mainWindow.webContents.isLoadingMainFrame()) {
return;
}
mainWindow.webContents.reloadIgnoringCache();
}
function forwardDeepLink(url: string): void { function forwardDeepLink(url: string): void {
const mainWindow = getMainWindow(); const mainWindow = getMainWindow();
@@ -96,13 +114,16 @@ export function initializeDeepLinkHandling(): boolean {
} }
app.on('second-instance', (_event, argv) => { app.on('second-instance', (_event, argv) => {
if (resolveDevSingleInstanceExitCode() != null && argv.includes(DEV_RELOAD_EXISTING_ARG)) { const action = resolveSecondInstanceAction({
app.relaunch(); argv,
app.exit(0); devSingleInstanceExitCode: resolveDevSingleInstanceExitCode()
return; });
}
focusMainWindow(); if (action === 'reload-existing') {
reloadMainWindow();
} else {
focusMainWindow();
}
const deepLink = extractDeepLink(argv); const deepLink = extractDeepLink(argv);
@@ -0,0 +1,44 @@
import {
describe,
expect,
it
} from 'vitest';
import { DEV_RELOAD_EXISTING_ARG, resolveSecondInstanceAction } from './second-instance.rules';
describe('resolveSecondInstanceAction', () => {
it('reloads the open window when a dev launch asks to reuse it', () => {
const action = resolveSecondInstanceAction({
argv: [
'electron',
'.',
DEV_RELOAD_EXISTING_ARG
],
devSingleInstanceExitCode: 23
});
expect(action).toBe('reload-existing');
});
it('never asks a packaged instance to reload, even with the dev argument', () => {
const action = resolveSecondInstanceAction({
argv: ['metoyou', DEV_RELOAD_EXISTING_ARG],
devSingleInstanceExitCode: null
});
expect(action).toBe('focus');
});
it('focuses the open window for an ordinary second launch', () => {
const action = resolveSecondInstanceAction({
argv: [
'electron',
'.',
'toju://invite/abc'
],
devSingleInstanceExitCode: 23
});
expect(action).toBe('focus');
});
});
+23
View File
@@ -0,0 +1,23 @@
export const DEV_RELOAD_EXISTING_ARG = '--metoyou-dev-reload-existing';
export type SecondInstanceAction = 'reload-existing' | 'focus';
export interface SecondInstanceInput {
argv: string[];
devSingleInstanceExitCode: number | null;
}
/**
* A dev launch always carries `--metoyou-dev-reload-existing`, so the running
* instance reloads in place. It must never answer by relaunching itself: the
* successor inherits the same argument and asks for the single-instance lock
* while the dying parent still holds it, so the refused successor fires
* `second-instance` again and the pair respawns forever.
*/
export function resolveSecondInstanceAction(input: SecondInstanceInput): SecondInstanceAction {
const isDevelopmentLaunch = input.devSingleInstanceExitCode != null;
return isDevelopmentLaunch && input.argv.includes(DEV_RELOAD_EXISTING_ARG)
? 'reload-existing'
: 'focus';
}
+48 -1
View File
@@ -11,6 +11,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules'; import { DESKTOP_APP_DISPLAY_NAME } from '../app/desktop-branding.rules';
import { readDesktopSettings } from '../desktop-settings'; import { readDesktopSettings } from '../desktop-settings';
import { DEV_CLIENT_LOAD_ATTEMPTS, loadDevelopmentClientWithRetry } from './dev-client-load.rules';
import { resolveDevelopmentClientUrl } from './dev-client-url.rules'; import { resolveDevelopmentClientUrl } from './dev-client-url.rules';
import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules'; import { shouldRegisterDisplayMediaHandler } from './display-media-handler.rules';
@@ -261,6 +262,52 @@ function ensureDisplayMediaRequestHandler(): void {
); );
} }
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function buildDevClientFailurePage(url: string, reason: string): string {
const escapedReason = reason.replace(/&/g, '&amp;').replace(/</g, '&lt;');
return `<!doctype html>
<html><body style="background:#0a0a0f;color:#e5e7eb;font:14px system-ui;padding:48px">
<h1 style="font-size:18px">The dev client did not load</h1>
<p>Could not load <code>${url}</code> after ${DEV_CLIENT_LOAD_ATTEMPTS} attempts.</p>
<p style="color:#f87171"><code>${escapedReason}</code></p>
<p>Check that <code>npm run dev</code> is still running, then reload with Ctrl+R.</p>
</body></html>`;
}
async function loadDevelopmentClient(window: BrowserWindow, url: string): Promise<void> {
let lastError: unknown = null;
const outcome = await loadDevelopmentClientWithRetry({
isAborted: () => window.isDestroyed(),
load: () => window.loadURL(url),
onGiveUp: (attempts, error) => {
lastError = error;
console.error(`[Window] Dev client at ${url} failed after ${attempts} attempts: ${describeError(error)}`);
},
onRetry: (attempt, error) => {
lastError = error;
console.warn(`[Window] Dev client at ${url} not ready (attempt ${attempt}): ${describeError(error)}. Retrying.`);
},
wait: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))
});
if (outcome !== 'failed' || window.isDestroyed()) {
return;
}
const failurePage = buildDevClientFailurePage(url, describeError(lastError));
try {
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(failurePage)}`);
} catch (error) {
console.error(`[Window] Could not show the dev client failure page: ${describeError(error)}`);
}
}
export async function createWindow(): Promise<void> { export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath(); const windowIconPath = getWindowIconPath();
@@ -290,7 +337,7 @@ export async function createWindow(): Promise<void> {
ensureDisplayMediaRequestHandler(); ensureDisplayMediaRequestHandler();
if (process.env['NODE_ENV'] === 'development') { if (process.env['NODE_ENV'] === 'development') {
await mainWindow.loadURL(resolveDevelopmentClientUrl(process.env['SSL'] === 'true')); await loadDevelopmentClient(mainWindow, resolveDevelopmentClientUrl(process.env['SSL'] === 'true'));
if (process.env['DEBUG_DEVTOOLS'] === '1') { if (process.env['DEBUG_DEVTOOLS'] === '1') {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
@@ -0,0 +1,72 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
DEV_CLIENT_LOAD_ATTEMPTS,
DevClientLoadDeps,
loadDevelopmentClientWithRetry
} from './dev-client-load.rules';
function createDeps(overrides: Partial<DevClientLoadDeps> = {}): DevClientLoadDeps {
return {
isAborted: () => false,
load: vi.fn().mockResolvedValue(undefined),
onGiveUp: vi.fn(),
onRetry: vi.fn(),
wait: vi.fn().mockResolvedValue(undefined),
...overrides
};
}
describe('loadDevelopmentClientWithRetry', () => {
it('loads once when the dev server answers', async () => {
const deps = createDeps();
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
expect(deps.load).toHaveBeenCalledTimes(1);
expect(deps.onRetry).not.toHaveBeenCalled();
});
it('retries a rebuild gap and reports the recovered load', async () => {
const load = vi.fn()
.mockRejectedValueOnce(new Error('ERR_CONNECTION_REFUSED (-102)'))
.mockResolvedValue(undefined);
const deps = createDeps({ load });
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('loaded');
expect(load).toHaveBeenCalledTimes(2);
expect(deps.onRetry).toHaveBeenCalledTimes(1);
expect(deps.wait).toHaveBeenCalledTimes(1);
});
it('reports failure instead of throwing so the caller still wires the window', async () => {
const error = new Error('ERR_FAILED (-2)');
const deps = createDeps({ load: vi.fn().mockRejectedValue(error) });
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('failed');
expect(deps.load).toHaveBeenCalledTimes(DEV_CLIENT_LOAD_ATTEMPTS);
expect(deps.onGiveUp).toHaveBeenCalledWith(DEV_CLIENT_LOAD_ATTEMPTS, error);
});
it('stops retrying once the window is gone', async () => {
let windowAlive = true;
const load = vi.fn().mockImplementation(() => {
windowAlive = false;
return Promise.reject(new Error('ERR_FAILED (-2)'));
});
const deps = createDeps({
isAborted: () => !windowAlive,
load
});
await expect(loadDevelopmentClientWithRetry(deps)).resolves.toBe('aborted');
expect(load).toHaveBeenCalledTimes(1);
expect(deps.onGiveUp).not.toHaveBeenCalled();
});
});
+49
View File
@@ -0,0 +1,49 @@
export const DEV_CLIENT_LOAD_ATTEMPTS = 10;
export const DEV_CLIENT_RETRY_DELAY_MS = 500;
export type DevClientLoadOutcome = 'loaded' | 'aborted' | 'failed';
export interface DevClientLoadDeps {
load: () => Promise<void>;
isAborted: () => boolean;
wait: (delayMs: number) => Promise<void>;
onRetry: (attempt: number, error: unknown) => void;
onGiveUp: (attempts: number, error: unknown) => void;
}
/**
* The dev client is served by a watch-mode build, so a load can fail for
* reasons that resolve on their own: a rebuild in flight, or a shutdown that
* aborted the navigation. Failing hard skipped every window listener
* registered after the load and left a blank window with no message, so this
* retries and always reports instead of throwing.
*/
export async function loadDevelopmentClientWithRetry(deps: DevClientLoadDeps): Promise<DevClientLoadOutcome> {
for (let attempt = 1; attempt <= DEV_CLIENT_LOAD_ATTEMPTS; attempt += 1) {
if (deps.isAborted()) {
return 'aborted';
}
try {
await deps.load();
return 'loaded';
} catch (error) {
if (deps.isAborted()) {
return 'aborted';
}
if (attempt === DEV_CLIENT_LOAD_ATTEMPTS) {
deps.onGiveUp(attempt, error);
return 'failed';
}
deps.onRetry(attempt, error);
await deps.wait(DEV_CLIENT_RETRY_DELAY_MS);
}
}
return 'failed';
}
+74
View File
@@ -0,0 +1,74 @@
# Emergency Fix Pack — MetoYou / Toju
> **Purpose:** Give other agents a single, user-first map of what this app is, how critical features are supposed to work, where the code lies, and the exact order to fix failures.
>
> **Constraint for this pack:** Analysis and planning only. Product code was not changed when this pack was authored (2026-08-12).
>
> **Primary surface:** `toju-app/` + targeted `electron/` (auth secret store / IPC). Expand to `server/` / `e2e/` only when a packet says so.
---
## How to use this pack
1. Read **this file** + `01-product-overview.md`.
2. Pick a work packet from `12-agent-work-packets.md` (do not invent parallel scope).
3. Open the matching issue file (`04``09`) for user story, intended behavior, failure modes, code paths, and proof.
4. Follow priority order in `11-fix-priority-plan.md` unless the user overrides.
5. Before coding: run the repo **interview-before-implement** ritual (choices A/B/C) unless the user says “just fix it” / “no interview” or an active handoff already records approved decisions.
6. When a packet finishes: update `11-fix-priority-plan.md` status checkboxes and clear or rewrite `agents-docs/HANDOFF.md` per `.cursor/rules/handoff.mdc`.
---
## File index
| File | Contents |
|------|----------|
| `00-README.md` | This index and rules of engagement |
| `01-product-overview.md` | What MetoYou/Toju is (user + architecture) |
| `02-user-journeys.md` | End-to-end journeys: login → rooms → chat → voice → multi-server |
| `03-architecture-map.md` | Domains, transports, identity model, key paths |
| `04-auth-login-bugs.md` | Login / authorize / silent provision failures |
| `05-signaling-multi-server.md` | Signal servers, WS drops, room affinity, presence |
| `06-voice-webrtc-bugs.md` | Voice, camera, screen-share, peer negotiation |
| `07-data-channel-drops.md` | Control-plane DC failures and recovery gaps |
| `08-messaging-visibility.md` | “Messages not seen” / sync / fallback |
| `09-identity-cross-signal.md` | Home vs foreign actor ids (calls, DMs, voice routing) |
| `10-code-lies-doc-debt.md` | Docs/lessons that disagree with the tree |
| `11-fix-priority-plan.md` | Ordered waves, acceptance, proof |
| `12-agent-work-packets.md` | Copy-paste packets for new chats |
| `13-validated-findings-fable-5-handoff.md` | Code-validated findings, corrected priorities, E2E limits, and implementation handoff |
| `e2e-failures/` | Latest full Playwright e2e run baseline (errors + slow tests) |
---
## Product in one paragraph (user view)
MetoYou (product client **Toju**) is a **desktop-first P2P chat app**: you log in once to a **signal server**, join **chat-servers** (communities with text + voice channels), talk in text, join voice, share screen/camera, and DM / call people. Media and most chat go **peer-to-peer over WebRTC**. The signal server only authenticates you, tracks who is in which room, and relays WebRTC offers / narrow chat & DM fallbacks. You may use **many signal servers**; the app is supposed to create a linked account on each new host **silently** after the first home login.
---
## Critical bug themes (user language)
1. **“It keeps asking me to log in”** even though Im already signed in (especially when joining something on another signal server).
2. **“Voice doesnt work / one-way / connecting forever”** — often after reconnect or when people registered on different signal servers.
3. **“Connection / data channel drops”** — chat, attachments, emoji, and sometimes voice die; recovery is silent or tears everything down.
4. **“I sent a message but they dont see it”** (or only some devices see it) — presence missing, P2P sync dead, or wrong identity.
5. **“Call rings nobody / Im In Voice alone”** — cross-signal identity mismatch on outbound or inbound paths.
6. **Different signal servers** make all of the above worse because **home user id ≠ foreign provisioned actor id**, and signaling is **not federated**.
---
## Related existing research (do not redo)
- Active research story: `agents-docs/user-stories/silent-cross-signal-server-auth.md`
- Feature contracts: `agents-docs/features/{authentication,signaling,voice-webrtc,messaging,server-directory}.md`
- Realtime deep dive: `toju-app/src/app/infrastructure/realtime/README.md`
- Lessons index: `agents-docs/LESSONS-INDEX.md` — tags `[auth] [realtime] [direct-call] [direct-message] [identity] [signaling]`
---
## Non-goals of this pack
- Implementing fixes (separate chats / packets).
- Rewriting the whole monorepo.
- Treating unit-green alone as “done” — each packet lists user-visible proof.
+118
View File
@@ -0,0 +1,118 @@
# 01 — Product overview (user + system)
## What the user thinks this app is
**Toju / MetoYou** is a Discord-like desktop chat app with:
- **Accounts** on a signal (signaling) server
- **Chat-servers** (communities) with text channels and voice channels
- **Live text chat** in those channels
- **Voice / camera / screen share** in voice channels
- **Direct messages** and **private calls**
- **Friends**, profile cards, custom emoji, GIFs, file attachments
- **Multiple signal servers** in the network settings (home + others)
The marketing promise implied by the product design:
> Log in once. Join communities anywhere on the network. Chat and voice “just work.” Switching signal hosts should not feel like logging into a second product.
---
## What the system actually is
| Layer | Role |
|-------|------|
| **Angular client** (`toju-app/`) | All UX, NgRx state, domain logic, WebRTC + WebSocket clients |
| **Electron shell** (`electron/`) | Desktop window, SQLite persistence, IPC (`window.api`), provision-secret safeStorage, screen capture helpers |
| **Signaling server** (`server/`) | Auth tokens, public server directory REST, WebSocket identify/join/presence, RTC offer/answer/ICE relay, narrow chat/DM/voice_state fallbacks, multi-device `account_sync`. **Does not store chat history.** |
| **Web / Capacitor** | Same Angular app; weaker secret storage (sessionStorage); mobile voice/UI constraints |
### Transport split (critical mental model)
| Transport | What the user experiences | What it actually carries |
|-----------|---------------------------|--------------------------|
| **WebSocket to signal server** | “Im online / in this server / someone joined voice” | Identity, room membership, presence, SDP/ICE relay, `chat_message` + DM fallbacks, `voice_state`, `account_sync` |
| **WebRTC media** | Hearing / seeing people | Mic, camera, screen tracks (never through the signal server) |
| **WebRTC ordered data channel** | Messages syncing, files, emoji, many “live” features | Chat events, inventory sync, attachments, avatar/emoji chunks, voice/screen control, plugin bus |
If the **socket** is wrong or unauthenticated → user is invisible; no relay; chat fallback may fail.
If the **data channel** is dead → live text may still limp via `chat_message`, but **history sync, attachments, emoji, many controls** fail.
If **media** is routed wrong or peer never connects → voice UI lies (“In Voice”) with silence.
---
## Core vocabulary (user ↔ engineering)
| User says | Engineering term |
|-----------|------------------|
| “My account / login” | Home session on `homeSignalServerUrl` + local profile in SQLite/IndexedDB |
| “Another network / signal host” | Foreign `ServerEndpoint` URL; needs per-URL credential |
| “A server / community” | Saved **Room** (chat-server) with `sourceUrl` / `sourceId` = which signal hosts it |
| “Text channel / voice channel” | Channels inside a room; text vs voice types |
| “Im in voice” | Local `VoiceSession` + `voice_state` with `isConnected` + mic ownership via `clientInstanceId` |
| “DM / call someone” | Direct-message conversation + optional `direct-call` session |
| “It logged me out” | Often `/login` or `/login?mode=authorize` — may be **foreign authorize**, not true home logout |
---
## Multi-signal identity (the footgun)
1. User registers on **Signal A** → home user id `H`.
2. User later needs **Signal B** → client is supposed to **auto-register/login** with a local **provision secret**, creating actor id `A_B` on B (username may be suffixed).
3. Peers on Signal B see the user as `A_B`, not `H`.
4. WebRTC peer map keys, `targetUserId` on relay, DM conversation ids, and call participant lists may mix `H` and `A_B`.
**Invariant the product claims:** one human, one local profile, many per-server actor credentials — UX never asks for password again except last-resort authorize / real home expiry.
**Invariant signaling claims:** non-federated — peers in the same room must share the **same signal endpoint** to discover each other. Cross-room “same person” is a client-side identity problem.
---
## Feature inventory (what “done product” includes)
Must work for a release-quality emergency fix:
1. Home register / login / logout / session restore
2. Silent foreign provision + Network settings badges
3. Discover / create / join / leave chat-servers (public, password, invite, moderated)
4. Text channels: send, edit, delete, react, typing, unread, sync after late join
5. Voice channels: join/leave, mute/deafen, speaking indicators, multi-device takeover, move between channels
6. Camera + screen share in voice / calls
7. DMs + friends + delivery states
8. Direct / group calls (ring, answer, decline, DND)
9. Attachments over data channel
10. Multi-device `account_sync` for saved rooms / chat batches / friends / avatar / emoji
11. Endpoint health, version compatibility, room signal affinity + fallback
Secondary (do not block P0 voice/auth/chat): plugins, game activity, themes, KLIPY, link previews, experimental media.
---
## Platforms
| Platform | Notes for bugs |
|----------|----------------|
| Electron desktop | Primary; provision secret in safeStorage; SQLite; best screen share |
| Browser | sessionStorage provision secret dies with tab; IndexedDB |
| Capacitor mobile | Auth routing, mic permissions, no reliable screen share; title bar hidden |
---
## Trust boundaries (short)
- REST mutations and WS `identify` require bearer/session token **per signal URL**.
- Actor user ids in request bodies are ignored server-side; token wins.
- Message bodies are **not E2E encrypted** beyond DTLS on WebRTC and TLS on WS.
- Signaling server is **not** the source of truth for chat history.
---
## Where truth lives (for agents)
| Kind of truth | Prefer |
|---------------|--------|
| Wire WebSocket types | `agents-docs/features/signaling.md` + `server/src/websocket/handler.ts`**not** `shared-kernel/signaling-contracts.ts` |
| Auth multi-server | `agents-docs/features/authentication.md` + `emergency-fix/04-*.md` |
| Voice/WebRTC plumbing | `toju-app/.../realtime/README.md` — verify against code (see `10-code-lies`) |
| Domain UX | `toju-app/src/app/domains/<name>/README.md` |
| Known past bugs | `agents-docs/LESSONS.md` — verify symbols still exist (some lessons describe **desired** fixes as if shipped) |
+256
View File
@@ -0,0 +1,256 @@
# 02 — User journeys (how it is supposed to feel)
Each journey: **steps the user takes**, **what should happen**, **failure symptoms**, **where to dig**.
---
## J1 — First-time home login
**User steps**
1. Open app → login/register.
2. Pick (or accept default) signal server.
3. Register or log in with username/password.
4. Land on dashboard / saved servers.
**Supposed to happen**
- Session token stored for that URL (`metoyou.authTokens` + credential store).
- Local user profile scoped in DB; NgRx `currentUser` set with `homeSignalServerUrl`.
- **Provision secret** created and persisted (Electron safeStorage / web sessionStorage).
- Signing public key registered on **home** server when possible.
- WebSocket connects, `identify` with home token + `clientInstanceId`, ready for joins.
**Broken looks like**
- Stuck on login; bounce back after “success”.
- Dashboard with no token → later `SESSION_EXPIRED`.
- Later foreign joins always open authorize (missing secret).
**Code**
- `domains/authentication/` (`AuthenticationService`, login/register UI)
- `store/users/users.effects.ts` (`authenticateUser`, `prepareAuthenticatedUserStorage`, `ensureHomeProvisionSecret`)
- Feature: `agents-docs/features/authentication.md`
---
## J2 — Restart app still logged in
**User steps**
1. Quit Electron fully; reopen.
2. Expect same user without typing password.
**Supposed to happen**
- Load user from local DB + valid home token (credential store or legacy token fallback).
- Identify on home (and later foreign) sockets.
- Opportunistic `ensureProvisioned` for active endpoints **without** login UI.
**Broken looks like**
- Flash of dashboard then `/login`.
- Profile restored but chat/presence dead (“alone”).
- Foreign rooms immediately open `/login?mode=authorize`.
**Code**
- `loadCurrentUser$`, `hasValidPersistedSession`, `migrateHomeCredential`
- Lessons: identify legacy token fallback; persisted user still needs token
---
## J3 — Join a community on the *same* signal server
**User steps**
1. Discover or invite → Join.
2. Open a text channel; send “hello”.
3. Join a voice channel; talk.
**Supposed to happen**
- REST join with bearer; then WS `join_server`.
- Receive `server_users` / `user_joined`; peer mesh forms.
- Text: local add + DC `chat-message` + WS `chat_message` fallback.
- Voice: `voice_state` broadcast; WebRTC offer/answer; same-channel audio routing.
**Broken looks like**
- Joined in UI but not in others member lists (identify/join race).
- Messages only on sender device.
- Voice tile appears, no audio.
---
## J4 — Join a community on a *different* signal server (critical)
**User steps**
1. Already logged into home Signal A.
2. Open invite / browser card whose `sourceUrl` is Signal B.
3. Join and chat/voice.
**Supposed to happen**
1. `ensureCredentialForServerUrl(B)` → silent provision (or reuse credential).
2. REST + WS use **actor id on B**, not home id.
3. No authorize login page.
4. Peers on B see actor display name (may show `#prefix` disambiguation).
5. Voice/chat use Bs WebSocket for that rooms affinity.
**Broken looks like**
- Redirect to `/login?mode=authorize&serverId=…` while user bar still shows logged in.
- Join “succeeds” locally but invisible on B.
- Can see members but WebRTC never connects (initiator/identity mismatch).
- DMs/calls to that person later miss rings or fork conversations.
**Code**
- `SignalServerAuthorizeService`, `SignalServerProvisionerService`, `room-signaling-connection.ts`
- Story: `agents-docs/user-stories/silent-cross-signal-server-auth.md`
- Pack: `04-auth-login-bugs.md`, `09-identity-cross-signal.md`
---
## J5 — Send a text message (server channel)
**User steps**
1. In text channel, type and send.
2. Peer in same room should see it live; late joiner should catch up after connecting.
**Supposed to happen**
- Optimistic local message with stable id (attachments bind to that id).
- Broadcast on data channel; also relay `chat_message` on signaling for peers without DC.
- Edits/deletes primarily P2P (+ `account_sync` to sibling devices).
- On peer connect: inventory ↔ sync-batch (up to 20k recent msgs, chunks of 200).
**Broken looks like**
- Sender sees it; others dont (no presence / no join / DC+fallback both fail).
- Others see live but not history (DC inventory never ran).
- Multi-device: one device has history, another empty (`account_sync` / identify).
- Attachments “Waiting for image…” forever (announce vs message ordering).
**Pack:** `08-messaging-visibility.md`
---
## J6 — Join voice in a channel
**User steps**
1. Click a voice channel.
2. Grant mic if prompted.
3. Hear others; they hear you; speaking indicators; optional camera/screen.
**Supposed to happen**
- Leave any previous voice/call first (exclusive).
- Publish `voice_state` with channel/server ids + `clientInstanceId`.
- Only one device owns mic (others passive; Join = takeover).
- Peer connections already for chat mesh; mic tracks attached only to same-channel peers.
- Playback only for peers in same voice channel.
**Broken looks like**
- “Connecting” forever.
- One-way audio.
- UI shows peers in channel but silent.
- After network blip: forever dead until full app restart.
- Works same-home, fails cross-home (initiator uses home id vs peer actor id).
**Pack:** `06-voice-webrtc-bugs.md`
---
## J7 — Call someone from DM / people card
**User steps**
1. Open DM or people card → Call.
2. Callee hears ring / sees modal (unless DND).
3. Answer → private call UI with optional chat panel.
**Supposed to happen**
- `direct-call` event delivered via PeerDelivery (DC then signaling).
- `targetUserId` = callees **currently connected signal identity**.
- Callee admission checks **all local aliases** (home + every provisioned actor id).
- Caller never sits “In Voice” if ring could not be delivered.
**Broken looks like**
- Caller In Voice; callee silent (outbound route null / wrong id).
- Callee never notified (inbound alias filter — partially fixed).
- Cross-signal: two DM threads; replies land in the “wrong” empty one.
**Pack:** `09-identity-cross-signal.md`
---
## J8 — Network blip / signal server restart
**User steps**
1. In voice + chat; WiFi blips or signal process restarts.
2. Continue without manual reconnect.
**Supposed to happen**
- WS reconnect with backoff; health probe forces fresh socket on instance change.
- `reIdentifyAndRejoin` then room resync.
- Peer disconnect grace 10s; then reconnect loop (~12 × 5s).
- DC close triggers repair; chat fallback covers live text meanwhile.
- Voice presence clears on dead voice-active disconnect server-side.
**Broken looks like**
- Zombie “online” with no events.
- Give-up after ~60s with **no error UI**.
- Docs promise soft DC replace; code tears down full peer (audio drop).
- Identify skipped → alone forever until manual leave/rejoin.
**Pack:** `05-signaling-multi-server.md`, `07-data-channel-drops.md`
---
## J9 — Two devices, same account
**User steps**
1. Desktop in voice; phone/browser also logged in.
2. Second device shows “in voice on another device”; can Takeover.
3. Chat/history appears on both.
**Supposed to happen**
- Distinct `clientInstanceId` per tab (sessionStorage).
- Broadcasts reach sibling connections; `account_sync` for owned state.
- Voice exclusive; takeover yields mic on old owner.
**Broken looks like**
- Tabs evict each other (shared clientInstanceId in localStorage — lesson says use sessionStorage).
- Second device never gets chat batches.
- Both think they own voice / neither transmits.
---
## J10 — Logout
**User steps**
1. Title-bar Logout (desktop) or Settings → Logout (mobile).
**Supposed to happen**
- Disconnect sockets; clear current user id; reset rooms/users/messages; `/login`.
**Broken looks like**
- Stale credentials for foreign URLs linger and confuse next account (verify credential clear scope when fixing auth).
+154
View File
@@ -0,0 +1,154 @@
# 03 — Architecture map (for agents)
## Bounded contexts (product client)
See `toju-app/src/app/domains/README.md`. Emergency-relevant domains:
| Domain | Owns |
|--------|------|
| `authentication` | Login/register HTTP, provision secret, per-URL credentials, authorize navigation |
| `server-directory` | Endpoints, health, discovery, invites, room metadata affinity |
| `chat` | Message rules, sync rules, chat UI |
| `direct-message` | DMs, friends, offline queue, PeerDelivery usage |
| `direct-call` | Private call sessions / rings |
| `voice-session` | Join/leave bookkeeping, floating controls, settings storage |
| `voice-connection` | Facade over realtime for mic/camera/playback/VAD |
| `screen-share` | Picker / quality |
| `attachment` | Chunked P2P files |
| `access-control` | Permissions / bans |
**Infrastructure (not a domain):** `infrastructure/realtime/` (WebRTC + signaling), `infrastructure/persistence/`, `infrastructure/mobile/`.
**Global NgRx:** `store/users`, `store/rooms`, `store/messages` — orchestration across domains.
---
## Identity & credentials
```
Home profile (NgRx User)
id / username / displayName / homeSignalServerUrl
├─ AuthTokenStore (legacy per-URL token) metoyou.authTokens
├─ SignalServerCredentialStore metoyou.signalServerCredentials
│ { serverUrl → userId, token, provisioned? }
└─ ProvisionSecretStore (per home user id)
Electron safeStorage | web sessionStorage
```
**Actor resolution for a room:** `SignalServerAuthService.resolveActorUserIdForServer(sourceUrl, homeOderId)` — foreign URL must use provisioned `userId`.
**Identify on socket:** `SignalingTransportHandler.getIdentifyCredentialsForSignalUrl(url)` must resolve token+actor for that URL (store fallback).
**Danger:** `getIdentifyCredentials()` returns **home** credential — used for some localOderId / polite-peer paths → cross-signal initiator bugs (`06`, `09`).
---
## Room ↔ signal affinity
```
Room { id, channels[], sourceUrl, sourceId, ... }
RoomSignalingConnection
ensureCredentialForServerUrl(sourceUrl)
identify(actor for sourceUrl)
join_server / view_server on that WS
ServerSignalingCoordinator
maps serverId ↔ signalUrl ↔ peers
```
Fallback: try other online compatible endpoints on outage; **do not** treat Cloudflare 521/522 as “client incompatible”. Non-federated: peers must converge on same endpoint for that room.
---
## Realtime composition root
`WebRTCService` (`realtime-session.service.ts`) wires:
- `SignalingTransportHandler` → many `SignalingManager` (one WS per URL)
- `PeerConnectionManager` → negotiation, DC, recovery
- `MediaManager` / noise / screen share
- `WebRtcStateController` (signals)
Inbound WS → `SignalingMessageHandler` → users/rooms/voice/chat effects.
---
## Message send path (server channel)
```
Composer → MessagesActions.sendMessage({ id? })
→ local DB + NgRx
→ DC broadcast chat-message (+ message-revision)
→ WS chat_message fallback (room members)
→ account_sync to sibling devices
```
Receive gates: room must be current or saved; channel scoping for text.
---
## Voice join path
```
UI join channel
→ VoiceSessionFacade startSession
→ leave previous exclusive target
→ enableVoice / heartbeat
→ voice_state on WS (+ DC control)
→ MediaManager.syncVoiceRouting (same channel only)
→ VoicePlaybackService for remote same-channel peers
```
Peer PC may already exist from presence mesh; join mainly attaches tracks + announces state.
---
## Call / DM delivery path
```
DirectCallService / DirectMessageService
→ PeerDeliveryService
1) data channel if open
2) signaling forward (targetUserId)
3) offline queue (DM) / silent fail (call if ignored)
```
Inbound call admission: `direct-call-participant-identity.rules.ts` (aliases — implemented).
Outbound routable id pick: **documented in LESSONS as fixed; symbols not in tree** — see `10-code-lies`.
---
## Recovery constants (cheat sheet)
| Constant | Value |
|----------|-------|
| WS reconnect backoff | 1s → 30s |
| Connect timeout | 5s |
| Keepalive interval / ack timeout | 25s / 10s |
| Health probe | 5s |
| Peer disconnect grace | 10s |
| Peer reconnect | 5s × 12 then silent abandon |
| DC recovery grace (closing) | 2.5s |
| Non-initiator give-up | 5s |
| Offer-sent grace | 20s |
| Inventory / full sync limit | 20_000 |
| Sync batch | 200 |
| Sync poll | 10s fast / 15 min slow |
Source: `realtime.constants.ts`, `message-sync.rules.ts`.
---
## Server responsibilities (only when needed)
Default agent scope excludes `server/` unless packet expands. Know this:
- Serializes WS handlers per connection (identify-before-join).
- Relays RTC only when peers share membership (DM types exempt).
- No message persistence.
- `voiceActive` routes offers to owning connection.
Canonical envelopes: `agents-docs/features/signaling.md`.
+119
View File
@@ -0,0 +1,119 @@
# 04 — Auth & login bugs
> **User theme:** “Im logged in but it keeps asking me to log in / authorize.”
> **Severity:** P0 — blocks multi-server chat, voice, presence.
> **Existing research:** `agents-docs/user-stories/silent-cross-signal-server-auth.md` (do not redo; implement after interview).
---
## How auth is supposed to work (user view)
1. Register or log in **once** on a home signal server.
2. Stay signed in across app restarts (desktop).
3. When touching another signal server (join, invite, create room, activate endpoint): the app **silently** creates or reuses an account there.
4. You only see a login form again if:
- true home session expired / missing token, or
- last-resort: username collision exhaustion / user clicked Sign in in Network settings.
5. Offline / dead endpoints never bounce you to authorize login.
Settings → Network may show `Authorized` / `Needs sign-in` as diagnostics — not as the default path for normal joins.
---
## How auth is supposed to work (system)
| Concept | Role |
|---------|------|
| Home credential | Token + user id for `homeSignalServerUrl` |
| Foreign credential | Separate user id + token per URL in `SignalServerCredentialStore` |
| Provision secret | Password used only for auto register/login on foreign hosts |
| `ensureProvisioned` | Register-or-login with secret; suffix username on collision |
| `ensureCredentialForServerUrl` | Gate before foreign room connect; navigate authorize only for `collision` or `no-provision-secret` when endpoint online |
| `authorize` mode | Manual login that upserts foreign credential **without** resetting home profile |
| `auth_required` vs `auth_error` | Race vs rejected token — must not falsely expire home on foreign races |
Authorize nav rule: `shouldNavigateToAuthorizeSignalServer` in `signal-server-authorize.rules.ts`.
---
## Failure modes (code-backed)
### A — Missing provision secret → authorize UI (primary)
**Symptom:** Logged-in user opens foreign room/invite → `/login?mode=authorize`.
**Mechanism**
1. `ensureProvisioned``{ kind: 'skipped', reason: 'no-provision-secret' }`.
2. `ensureCredentialForServerUrl` navigates to authorize.
3. Authorize mode does not auto-leave when `currentUser` exists → stuck prompt.
**Why secret missing**
- Created only in `prepareAuthenticatedUserStorage` when `homeSignalServerUrl` + `loginResponse` present.
- Session restore calls `ensureProvisioned` but **does not** ensure secret exists first.
- Web: sessionStorage secret dies with tab.
- Old installs / wiped Electron `userData/provision-secrets/`.
**Files**
- `signal-server-auth.service.ts`, `signal-server-authorize.service.ts`
- `provision-secret-store.service.ts`, `electron/api/provision-secret-store.ts`
- `users.effects.ts`
### B — Username collision exhaustion
All register candidates 409 + login 401 → `collision` → authorize. Rare but real on crowded foreign servers.
### C — False home session expiry
`signalServerAuthFailed$` may `SESSION_EXPIRED` → full `/login` when home classification / retry budget wrong. Distinguish `auth_required` (re-identify) vs `auth_error` (clear credential; foreign re-provision vs home expire).
### D — Credential missing → unauthenticated join → invisible user
Without token, socket never identifies; `join_server` dropped; user alone; later gates open authorize. Cascades into “messages not seen” and “voice empty”.
### E — Opportunistic provision swallows errors
`ensureProvisioned(...).catch(() => undefined)` on health/startup → failure deferred until mid-join authorize popup.
### F — Others?
---
## Doc / README lies in this area
| Claim | Reality |
|-------|---------|
| Auth domain README sequence: `POST /api/auth/login` | Real paths: `/api/users/login`, `/api/users/register` (`AuthenticationService`) |
| Feature doc: offline must not open authorize | Code path exists; still fails open on missing secret when “online” |
---
## Fix directions (interview choices — not approved)
From user story (recommend **A**):
| Option | Idea |
|--------|------|
| **A** | On restore / before foreign provision, always `ensureHomeProvisionSecret`; keep authorize only for collision / manual |
| **B** | Never navigate on `no-provision-secret`; toast + Network badge |
| **C** | Durable web secret (not sessionStorage) |
| **D** | Stronger unique usernames before collision UI |
---
## Proof of done
1. Two live signal servers: home register on A → join room on B → **no** `/login` navigation.
2. Full Electron restart → foreign rejoin still silent.
3. Offline foreign URL → no authorize navigation.
4. Focused tests: missing secret on restore → secret created → provision → `Router.navigate(['/login'])` never called.
5. Foreign `auth_error` with valid home → re-provision, not `SESSION_EXPIRED`.
---
## Agent scope
- Default: `toju-app/domains/authentication`, `store/users`, `store/rooms/room-signaling-connection.ts`, server-directory call sites.
- Electron: provision-secret store/IPC only if persistence fix needs it.
- Ask before deep `server/` auth changes.
+112
View File
@@ -0,0 +1,112 @@
# 05 — Signaling & multi-signal-server connection
> **User theme:** “Im connected but nobody sees me / rooms flicker / different signal servers break everything.”
> **Severity:** P0 for presence and as root cause of voice + chat visibility.
---
## How signaling is supposed to work (user view)
- When you open a community, you appear in the member list for everyone in that community.
- Leaving / going offline shows you as offline (without duplicate leave spam for multi-device).
- Switching communities updates who you see without wiping presence for other saved servers.
- If the signal host restarts or WiFi blips, you come back automatically.
- Communities hosted on different signal hosts still work as long as your client is authorized on that host (see `04`).
- Users should be able to see/sync chats/files/profile images/server states info change/plugin states and events/talk without losing connectivity or only some users are seen and other aren't.
---
## How signaling is supposed to work (system)
1. **One WebSocket per signal URL** (`SignalingManager`).
2. **Identify first** with that URLs token + actor `oderId` + `clientInstanceId`.
3. Then `join_server` / `view_server` for rooms on that URL.
4. Server serializes handlers per connection so join cannot race mid-identify.
5. Client `reIdentifyAndRejoin` on reconnect; rooms effects resync as safety net.
6. Room traffic prefers room `sourceUrl`; temporary fallback to other compatible endpoints on outage.
7. **Non-federated:** Signal A does not share peer registry with Signal B. Same human on two hosts = two actor ids (client glue).
Canonical catalog: `agents-docs/features/signaling.md`.
Client map: `infrastructure/realtime/README.md` + `signaling/`.
---
## Failure modes
### 1 — Identify / join race or skipped identify
**Symptom:** Local UI shows room open; others dont see you; you miss `user_joined` / `chat_message`; “alone”.
**Causes**
- Join sent before identify (fixed server-side serialization + client reIdentify — regressions still dangerous).
- `getIdentifyCredentialsForSignalUrl` returns null (no credential) → no identify.
- Identify cache empty on fresh socket without store fallback (lesson: must fall back to credential store).
### 2 — Wrong socket / wrong affinity
**Symptom:** Presence or RTC relay never reaches peers in the room; works only when everyone shares the same endpoint URL alias.
**Causes**
- Room `sourceUrl` stale vs directory `serverInstanceId` canonicalization.
- Fallback broadcast regressions (raw room messages must not spam every manager when route unknown — README describes current intended behavior).
- Cold start reconnect before health probes collapse aliases.
### 3 — Multi-signal leave tears down wrong peers
**Symptom:** Leaving a room on signal-sweden drops a peer still shared via signal.toju.app.
**Intended:** `user_left` subtracts only that clusters shared servers; preserve routes while P2P still live.
### 4 — Half-open / zombie sockets
**Symptom:** UI “connected”; no events after server process restart.
**Mitigations in tree:** keepalive ack timeout after first ack; `/api/health` probe; `serverInstanceId` change forces new WS. Regressions: skipping first heartbeat tick, treating 521/522 as incompatible.
### 5 — Auth failure cascade
Foreign `auth_required` / `auth_error` without silent re-provision → authorize UI or invisible membership (`04`).
### 6 — Multi-device eviction loop
Shared `clientInstanceId` in localStorage across tabs → server evicts sibling on identify. Must stay in **sessionStorage**.
---
## Different signal servers — user-visible contract
| Situation | Expected |
|-----------|----------|
| Two users, same room, same signal URL | Discover each other; RTC relay OK |
| Two users, “same” community mirrored on different signals | **Not supported as one mesh** — they are different rooms unless directory/affinity converges them |
| One user, rooms on A and B | Two sockets; two actor ids; local profile one |
| DM/call across people who met on foreign rooms | Must address **connected** actor id (see `09`) |
---
## Key files
- `signaling.manager.ts`, `signaling-transport-handler.ts`, `server-signaling-coordinator.ts`
- `signaling-message-handler.ts`, `server-membership-signaling-handler.ts`
- `room-signaling-connection.ts`, `rooms.effects.ts`
- `server-directory` health / canonical endpoint rules
- Server: `server/src/websocket/handler.ts` (out of default scope — ask)
---
## Proof of done (signaling)
1. Two clients join same room: both appear in member lists within seconds of identify.
2. Kill signal process; both recover presence without manual leave/rejoin.
3. User with rooms on two URLs: leave on URL A does not remove voice peer still shared only via URL B.
4. Fresh connect: first outbound after open is identify; join never accepted unauthenticated (server log / test).
5. Alias URLs with same `serverInstanceId` collapse before room reconnect.
---
## Fix order notes
Usually fix **auth provision (`04`)** before deep signaling surgery — many “connection” bugs are missing identify. Then harden identify credential resolution and affinity. Instrument `connectionScope`, actor id, and signal URL on every join in debug builds.
+130
View File
@@ -0,0 +1,130 @@
# 06 — Voice & WebRTC bugs
> **User theme:** “Voice is broken / one-way / connecting forever / works until reconnect.”
> **Severity:** P0.
---
## How voice is supposed to work (user view)
1. Click a voice channel (or answer a call).
2. Mic turns on (unless muted); you appear in the channel roster for others.
3. You hear everyone in **that** channel; they hear you.
4. Mute / deafen / camera / screen share behave Discord-like.
5. Navigate away → floating controls; voice continues.
6. Joining another voice target auto-leaves the previous one.
7. Second device shows youre in voice elsewhere; Join takes over.
8. After a short network blip, voice returns without restarting the app.
Voice is **not** supposed to depend on the data channel for audio itself — but DC carries control/state; broken DC recovery currently **rebuilds the whole peer**, which drops media too (`07`).
The users should always be able to hear each other and see each other in joined calls and voice channels. (Not 1 out of 6 cant be heard for 3 users!)
---
## How voice is supposed to work (system)
| Piece | Responsibility |
|-------|----------------|
| `voice-session` | Session metadata, floating UI, settings, exclusivity, takeover rules |
| `voice-connection` | Facade, VAD, per-peer playback gain |
| `MediaManager` | getUserMedia, RNNoise, gain, same-channel track routing |
| `PeerConnectionManager` | RTCPeerConnection, negotiation, ICE |
| Signaling | `offer`/`answer`/`ice_candidate` relay; `voice_state`; `voice_client_takeover` |
| ICE | STUN defaults; TURN optional via settings — **no bundled TURN** |
Initiator election: deterministic compare of local id vs peer `oderId` so only one side offers.
Audio routing: attach/detach mic based on matching `voiceState.roomId` + `serverId`. Playback similarly scoped.
---
## Failure modes
### 1 — Cross-signal initiator / politeness uses home id
**Symptom:** Peers never connect or glare forever when users have different home servers / foreign actor ids in the room.
**Mechanism**
- Presence peer ids = **per-server actor** `oderId`.
- `getLocalOderId` / polite-peer path often uses `getIdentifyCredentials()`**home** id (`signaling-transport-handler.ts`, `realtime-session.service.ts`, `negotiation.ts`).
- Election `localOderId < peerId` inconsistent across clients → dual offer, dual wait, or stuck non-initiator.
**Fix direction:** elect and politeness using **per-signal-url** identify credentials (`getIdentifyCredentialsForSignalUrl(peerSignalUrl)` / room source URL), not home-only.
### 2 — Voice allow-list misses peer map key → one-way / silence
**Symptom:** Connected peer, speaking UI maybe wrong, no audio out or in.
**Mechanism:** `syncOutgoingVoiceRouting` / playback only recognizes certain aliases (`id` / `oderId` / `peerId`). If `activePeerConnections` key is another alias → track detached.
### 3 — Silent give-up after reconnect budget
**Symptom:** After ~60s of failures, voice never returns; no error toast.
**Constants:** `PEER_RECONNECT_MAX_ATTEMPTS = 12`, interval 5s. Tracker deleted; no UI.
### 4 — Transient signaling drops offers during WS reconnect
Offers/ICE classified transient may be deferred/dropped while socket reconnecting → half-open peers; depends on fallback offer timers (`USER_JOINED_FALLBACK`, non-initiator give-up 5s).
### 5 — DC recovery tears down media
Closed control channel → `removePeer` + full reconnect (`peer-recovery.ts`). Docs claim soft `replaceDataChannel` preserving AV — **code does not do that** (`07`, `10`).
### 6 — Auth / presence missing
If identify/join failed (`04`/`05`), no RTC relay eligibility / no peer discovery → empty voice.
### 7 — Same-channel filter false negatives
Remote `voice_state` missing/stale channel ids → locally mute peer while UI still lists them in channel.
### 8 — No TURN by default
Symmetric NAT / strict firewalls fail ICE with STUN-only. User-configurable TURN exists; many installs never set it. Product decision needed: ship defaults vs document limitation.
### 9 — Multi-device ownership races
Takeover / heartbeat / `voiceActive` routing wrong → offers hit passive device; active device silent.
---
## Code vs docs
| Doc claim | Code |
|-----------|------|
| Soft DC renegotiation preserves media | Always full peer recreate on closed DC |
| Deterministic initiator from logical peer ids | Uses home identify credentials in several paths |
| TURN supported | Configurable only; not default |
---
## Key files
- `domains/voice-session/`, `domains/voice-connection/`
- `infrastructure/realtime/media/media.manager.ts`
- `peer-connection-manager/**`, `negotiation.ts`, `peer-recovery.ts`
- `signaling-message-handler.ts` (server_users / user_joined offers)
- `ice-server-settings.service.ts`, `realtime.constants.ts`
- Feature: `agents-docs/features/voice-webrtc.md`
---
## Proof of done
1. Two users same home signal, same voice channel: bidirectional audio < 5s after both join.
2. Two users **different home signals**, same foreign-hosted room (both provisioned): bidirectional audio.
3. Toggle mute/deafen; camera; screen share request path.
4. Kill WiFi 15s: recovers or shows actionable error (not silent forever).
5. Second client takeover: first stops transmitting; second owns mic.
6. Regression test for initiator election with mismatched home vs actor ids.
---
## Interview prompts (when implementing)
- Soft DC replace vs keep full rebuild but fix media reattach + UX error?
- Ship public TURN defaults or document “requires open NAT / user TURN”?
- Instrument-only first week vs behavior fix first?
+99
View File
@@ -0,0 +1,99 @@
# 07 — Data channel drops & recovery
> **User theme:** “Connection drops / chat and files die / voice dies after a blip.”
> **Severity:** P0/P1 — shared control plane for chat sync, attachments, emoji, screen control, and currently media (because recovery rebuilds the whole PC).
---
## How the data channel is supposed to work (user view)
- Once youre in a community with other people, messages, files, emoji, and many live updates “just sync.”
- Brief network glitches should self-heal.
- You should not need to restart the app to get chat syncing again.
- Voice should ideally survive control-plane blips (product docs claim this; code currently does not).
- Users should sync all but only load a portion into ram for viewing so the app doesn't crash of high ram usage.
---
## How the data channel is supposed to work (system)
- Single **ordered** RTCDataChannel per peer pair (label typically chat/control).
- Carries: chat events, inventory sync, attachments, avatar/emoji chunks, voice/screen control, pings, plugin bus, game activity, etc.
- Back-pressure: high 4MB / low 1MB watermarks.
- Ping every 5s for RTT.
- On failure, recovery should restore control **and** ensure inventory/resync runs when channel reopens.
### Documented recovery (README / voice-webrtc.md)
1. Non-fatal error on **open** channel → request voice-state snapshot on same channel.
2. **Closed** channel → initiator renegotiates **new DC on existing PC** (preserve AV); non-initiator waits then full rebuild if missing.
3. Closing-but-not-closed → short grace (2.5s).
4. `replaceDataChannel` adopts the new channel.
### Actual recovery (`peer-recovery.ts`)
1. Closed → `repairUnavailableDataChannel``removePeer` + `attemptPeerReconnect` / `schedulePeerReconnect` (**full PC teardown**).
2. Closing → wait `DATA_CHANNEL_RECOVERY_GRACE_MS` (2.5s) → same full recreate.
3. `replaceDataChannel` exists on the manager and is wired into handlers, but **recovery path never calls it**; specs assert it is **not** called in several cases.
4. After 12 reconnect attempts (~60s): abandon **silently**.
**This is a documented lie** — treat README paragraph as aspirational until code matches or docs are corrected in the same PR as a deliberate decision.
---
## Failure modes
### 1 — Full rebuild drops audio/video on every DC close
User hears a “drop” even when ICE media might have survived. Cascades into voice bug reports.
### 2 — Silent abandon
No toast, no “Reconnect” CTA, no automatic retry on later `user_joined`. Mesh looks permanently broken until navigation/restart.
### 3 — Live chat limp vs history dead
While DC down, `chat_message` WS fallback may still deliver **live** text. Inventory sync is **DC-only** → late joiners / catch-up fail until P2P returns (`08`).
### 4 — Large payloads kill shared channel
Custom emoji / attachment floods can stress or close the shared ordered channel (lessons). One feature outage becomes total control-plane outage.
### 5 — Attachment announce vs message ordering
`file-announce` on DC can beat `chat-message` on WS → auto-download gives up unless re-queued on message bind (lesson; verify still present when touching attachments).
### 6 — Replacement channel race
If soft-replace is reintroduced, must close old channel to release SCTP (voice-webrtc changelog). Current full rebuild avoids that class but at higher cost.
---
## Key files
- `peer-connection-manager/messaging/data-channel.ts`
- `peer-connection-manager/recovery/peer-recovery.ts` (+ specs)
- `peer-connection.manager.ts` (`replaceDataChannel`)
- `realtime.constants.ts`
- Consumers: chat sync effects, attachment transfer, custom emoji chunking, screen-share request
---
## Fix directions (interview)
| Option | Idea | Tradeoff |
|--------|------|----------|
| **A (align code to docs)** | Implement true soft DC replace on connected PC; full rebuild only if PC not connected | Harder; matches user expectation for voice survival |
| **B (align docs to code)** | Keep full rebuild; fix reattach + force inventory on reopen; **surface give-up UX** | Faster; still interrupts voice |
| **C** | Separate unreliable channel for bulk (files/emoji) vs reliable small control | Larger design |
Recommend starting with **instrumentation + Bs UX/resync**, then **A** if voice drop rate stays high.
---
## Proof of done
1. Force-close DC in debug: control messages resume; inventory runs; user sees progress or success.
2. If soft-replace chosen: audio continues through DC replace (automated or manual with metrics).
3. After max attempts: visible error + manual retry works.
4. Attachment + emoji transfer during recovery does not deadlock the mesh.
5. Update `realtime/README.md` + `voice-webrtc.md` in the same change set so they match behavior.
+110
View File
@@ -0,0 +1,110 @@
# 08 — Messaging visibility (“messages not seen”)
> **User theme:** “I sent a message but they dont see it / history missing / only some devices have chat.”
> **Severity:** P0.
---
## How messaging is supposed to work (user view)
### Server text channels
- Send in a text channel → everyone currently in that community sees it quickly.
- If someone was offline or just joined, they still get recent history after connecting to peers (or from their other logged-in device).
- Edits, deletes, reactions converge.
- Typing indicators are ephemeral.
- The **signal server does not keep a chat log** — history lives on clients.
### Direct messages
- 1:1 and group DMs deliver even without a shared community when signaling can reach the peer.
- Delivery ticks: queued → sent → delivered → acknowledged (monotonic).
- Offline: queue until peer/network returns.
### Multi-device
- Second device receives live + catch-up via `account_sync` when siblings are online.
---
## How messaging is supposed to work (system)
| Path | Role |
|------|------|
| P2P `chat-message` / revisions | Primary live + sync plane |
| WS `chat_message` | Narrow **live** fallback to room members |
| Inventory / sync-batch | Catch-up on DC (limit 20_000, chunk 200) |
| `account_sync` chat batches | Sibling devices |
| DM PeerDelivery | DC → signaling → offline queue |
Feature contract: `agents-docs/features/messaging.md`.
Domain: `domains/chat/`, `domains/direct-message/`, `store/messages/`.
---
## Failure modes
### 1 — Invisible membership → no live fallback
If identify/join failed (`04`/`05`), user is not in server membership → server never broadcasts `chat_message` to them; peers may not offer DC. **Classic “chats dont sync for multi-client users”** root cause (serialized identify).
### 2 — Live works, history doesnt
DC down: live WS fallback OK; inventory never runs → “they only see new messages after refresh if a peer happens to sync later” / empty history for late joiners.
### 3 — Sync poll too slow after “clean” cycle
Fast poll 10s while catching up; **15 min** when clean. A false “clean” leaves long windows without repair.
### 4 — Cross-signal identity forks DMs
Incoming DM `conversationId` carries foreign actor id → second empty thread; replies invisible on the thread the user is watching (`09`). Lessons claim canonicalize/merge — **symbols not in tree**.
### 5 — Recipient alias miss
DM/call ignored if local admission only checks home id. Inbound DM aliases largely fixed; verify call + any new surfaces.
### 6 — Attachment-only emptiness
Message text arrives; image stuck “Waiting…” — announce/bind race (lesson). Looks like “message incomplete / not really received.”
### 7 — NgRx prune confusion
Inactive rooms pruned to 100 messages in memory; DB still has more. User switching rooms may think history vanished until reload/sync — document vs bug.
### 8 — Doc lie on inventory cap
Chat domain README still says **1000**; code `INVENTORY_LIMIT = 20_000`.
---
## Investigation checklist (agents)
1. Did both users `identify` + `join_server` on the **same** signal URL as the room?
2. Is there an open DC between them? (`connectedPeers`, debug metrics)
3. Does live send emit both DC and `chat_message`?
4. On receive, is `roomId` in saved/current rooms?
5. For DM: conversation id aliases; delivery state machine stuck at QUEUED?
6. Multi-device: `account_sync_peer_online` fired; batches received?
---
## Proof of done
1. Two clients: send N messages with DC disabled (force) → live still appears via WS.
2. Re-enable DC → inventory brings missing history.
3. Third client late join → receives recent history from a peer with DB.
4. Cross-home users in foreign room: messages visible both ways without authorize prompt.
5. Cross-signal DM: single conversation thread; replies visible to both.
6. Multi-device: second device gets `chat-sync-batch` after identify.
7. Regression covering identify-before-join (presence + chat broadcast).
---
## Fix order
1. Auth + identify (`04`/`05`) — without presence, messaging “fixes” are theater.
2. Ensure fallback + DC resync on repair (`07`).
3. Identity canonicalize for DMs (`09`).
4. Attachment re-queue invariants.
5. Correct stale chat README inventory number.
+113
View File
@@ -0,0 +1,113 @@
# 09 — Cross-signal identity (calls, DMs, voice routing)
> **User theme:** “Calls dont ring / Im In Voice alone / DMs fork / voice fails only with people on other homes.”
> **Severity:** P0.
> **Coupling:** Depends on silent provision (`04`) creating foreign actor ids at all.
---
## How identity is supposed to work (user view)
- You have one profile on this device.
- People may see slightly different usernames on other signal hosts (suffix / `#prefix` tag) — still you.
- Calling or DMing someone you met in any community should reach **them**, not a ghost.
- You should never end up with two chat threads that are secretly the same person.
- Voice in a shared room should work even if you registered on different home signal servers.
---
## How identity is supposed to work (system)
| Id | Meaning |
|----|---------|
| Home user id | Local profile / NgRx `User.id` |
| Foreign actor id | Credential `userId` for that signal URL |
| Peer map key | Usually the `oderId` seen on that signals presence |
| `targetUserId` on WS relay | Must equal callees **connected** `oderId` on that server |
| DM `conversationId` | Should canonicalize to one thread per human pair on this device |
Self-admission for inbound events must accept **all** aliases: home id, entity id, peer id, every valid provisioned credential user id.
Outbound delivery must pick a **routable** id (one the signaling server can map to an open connection), not merely the home id stored on a people card.
---
## What is implemented vs claimed
| Capability | Status (2026-08-12 tree) |
|------------|--------------------------|
| Inbound direct-call alias admission + normalize | **Present**`direct-call-participant-identity.rules.ts` |
| DM self-id alias sets | **Present**`direct-message-identity.rules.ts` (narrower than lessons conversation merge) |
| Outbound `collectRecipientDeliveryCandidateIds` / `pickRoutableRecipientId` | **Absent** — lesson describes as if shipped |
| `DirectCallService.resolveRoutableRecipientId` / `recipientUnreachable` UX | **Absent** |
| `resolveDirectConversationId` / `mergeAliasDirectConversations` | **Absent** |
| Room join uses `resolveActorUserIdForServer` | **Present**`room-signaling-connection.ts` |
| Initiator election uses per-signal actor id | **Weak / home-biased** — see `06` |
**Treat LESSONS entries for outbound call routing and DM canonicalize as specifications of unfinished work**, not as completed history.
---
## Failure modes
### 1 — Outbound call: silent “In Voice”
Caller joins call session; `PeerDeliveryService` cannot resolve signaling peer id (home id ≠ connected actor id); ring never sent or wrong `targetUserId`; delivery result ignored.
### 2 — Inbound call: dropped ring (historical)
Fixed for admission aliases; still verify e2e `dm-header-call-ring` across secondary signal registration. Do not assume outbound is fixed because inbound is.
### 3 — DM thread fork
Incoming messages keyed by foreign actor conversation id → UI shows empty home-id thread; replies land elsewhere.
### 4 — Voice peer election / routing alias miss
Home vs actor mismatch → no PC or one-way audio (`06`).
### 5 — People search / friends store home ids only
Cards display home identity; without alias expansion at send time, every cross-signal action is fragile.
---
## Key files
- `domains/direct-call/` (+ `direct-call-participant-identity.rules.ts`)
- `domains/direct-message/` (+ `direct-message-identity.rules.ts`, `PeerDeliveryService`)
- `domains/authentication/` credential store + `resolveActorUserIdForServer`
- `infrastructure/realtime/signaling-transport-handler.ts` (home vs per-URL credentials)
- Lessons (aspirational): outbound routing + DM canonicalize sections in `LESSONS.md`
- E2E intent: `e2e/tests/voice/dm-header-call-ring.spec.ts` (scope expand if needed)
---
## Recommended implementation sequence
1. **Instrument** one cross-home repro: log home id, actor ids, peer map keys, `targetUserId`, conversation ids.
2. **Outbound delivery** — implement lesson APIs for real: collect aliases, pick routable, always attempt send, surface unreachable error (stop fake In Voice).
3. **DM canonicalize + merge** — single thread; remap inbound; merge duplicates on load.
4. **Voice initiator / routing** — per-signal local actor id (`06`).
5. Rewrite lesson examples if names differ so future agents dont hunt phantom files.
---
## Proof of done
1. User1 home A, User2 home B, meet in room on A: DM-header call rings Bs modal; B answers; both hear audio.
2. Same pair: DM replies appear in **one** thread on both clients.
3. People-card call when only shared presence is under actor id: still rings or shows **unreachable** (never silent In Voice).
4. Unit tests for alias collect/pick and conversation merge.
5. E2E cross-signal call + DM (expand scope with user approval for `e2e/`).
---
## Interview choices
| Option | Focus first |
|--------|-------------|
| **A (recommended)** | Outbound routable id + unreachable UX |
| **B** | DM canonicalize/merge first |
| **C** | Voice initiator per-signal id first |
| **D** | Full identity service refactor (large — avoid in emergency) |
+101
View File
@@ -0,0 +1,101 @@
# 10 — Code lies & documentation debt
> Agents must **verify symbols in the tree** before trusting lessons or READMEs. This file lists known mismatches found 2026-08-12.
---
## Critical lies (behavior-affecting)
### L1 — Soft data-channel replace (docs yes, code no)
**Claims:** `toju-app/.../realtime/README.md` (Data channel section), `agents-docs/features/voice-webrtc.md`.
**Reality:** `repairUnavailableDataChannel` always `removePeer` + reconnect. `replaceDataChannel` not used by recovery; specs expect it not called.
**Action:** Either implement soft replace or rewrite docs in the same PR as the recovery decision (`07`).
### L2 — LESSONS outbound call routing “fixed”
**Claims:** `LESSONS.md``peer-delivery-identity.rules.ts`, `collectRecipientDeliveryCandidateIds`, `pickRoutableRecipientId`, `resolveRoutableRecipientId`, `call.errors.recipientUnreachable`.
**Reality:** **No matches** in `toju-app/`. Phantom APIs.
**Action:** Implement (`09`) or rewrite lesson as “desired / unfinished” with Status.
### L3 — LESSONS DM conversation canonicalize “fixed”
**Claims:** `resolveDirectConversationId`, `mergeAliasDirectConversations`, `direct-message-conversation-identity.rules.ts`, etc.
**Reality:** **Absent.** Only narrower `direct-message-identity.rules.ts`.
**Action:** Same as L2.
### L4 — Home identify credentials used as “local peer id” for negotiation
**Claims:** Deterministic initiator from logical peer ids (implies per-room identity).
**Reality:** `getIdentifyCredentials()` returns **home** credential; used for polite peer / localOderId paths while presence uses actor ids.
**Action:** Fix in voice/identity packets (`06`/`09`); update README after.
---
## Medium lies (wrong paths / stale numbers)
### L5 — Authentication domain README API paths
**Claims:** `POST /api/auth/login`, `/api/auth/register`.
**Reality:** `/api/users/login`, `/api/users/register`.
### L6 — Chat domain README inventory cap
**Claims:** capped at **1 000** messages.
**Reality:** `INVENTORY_LIMIT = 20_000` (messaging feature doc correct).
### L7 — `shared-kernel/signaling-contracts.ts` as wire authority
**Claims (implicit):** types like `join` / `leave` / `chat` / `ice-candidate`.
**Reality:** Feature `signaling.md` correctly says **do not treat as authoritative**. Live types use `join_server`, `chat_message`, `ice_candidate`, etc.
**Action:** Mark file deprecated or align types; never generate client sends from it blindly.
---
## Soft / incomplete docs (not lies, but traps)
| Topic | Note |
|-------|------|
| Auth feature doc vs missing secret | Doc describes intended offline/authorize rules; primary bug is still open (user story). |
| TURN | Documented STUN-only defaults — accurate; users may think voice “should always work on restrictive NAT”. |
| Domain auth README | Oversimplified sequence diagram (no provision secret / multi-credential). |
| Handoff silent auth | Accurate research; not a code lie — unfinished work. |
---
## Verification commands for agents
```bash
# Phantom lesson APIs (should be empty until implemented)
rg -n 'pickRoutableRecipientId|resolveDirectConversationId|mergeAliasDirectConversations|peer-delivery-identity' toju-app
# DC recovery behavior
rg -n 'repairUnavailableDataChannel|replaceDataChannel' toju-app/src/app/infrastructure/realtime
# Inventory limit
rg -n 'INVENTORY_LIMIT' toju-app/src/app/domains/chat
# Auth paths
rg -n 'users/login|auth/login' toju-app/src/app/domains/authentication
```
---
## Policy for this emergency
1. Prefer **feature docs + code** over domain README diagrams when they conflict.
2. Prefer **code + specs** over LESSONS examples when symbols missing.
3. When fixing behavior, **fix the lying doc in the same PR**.
4. When a lesson was aspirational, relabel it explicitly so the next agent does not skip the work.
+153
View File
@@ -0,0 +1,153 @@
# 11 — Fix priority plan (systematic waves)
> Execute in order unless the user explicitly reprioritizes.
> Each wave lists **user outcome**, **work**, **depends on**, **proof**.
> Checkboxes are for agents to update when a wave is done.
---
## Guiding principles
1. **User-visible done** > unit-green alone.
2. Fix **identity + auth** before chasing random WebRTC knobs — many voice/chat bugs are “never identified / wrong id.”
3. **Interview before implement** on each wave (repo rule) unless opted out / handoff already approved.
4. Stay in `toju-app` + targeted `electron`; ask once before `server/` or full `e2e/`.
5. Correct lying docs in the same change (`10`).
---
## Wave 0 — Shared baseline (half day)
- [ ] **0.1** Read `00-README.md`, `01`, `03`, `10`.
- [ ] **0.2** Build a **cross-home repro matrix** (manual or scripted):
- Same home, same room
- Different homes, room on home A
- Different homes, room on foreign B (provision required)
- DM + call between different homes
- [ ] **0.3** Enable WebRTC/signaling debug logs; capture home id, actor ids, peer keys, signal URLs on failure.
- [ ] **0.4** Confirm whether provision secret exists after fresh login vs after restart (Electron + browser).
**Proof:** Written repro notes attached to the next handoff (short).
---
## Wave 1 — Silent multi-signal auth (P0)
**User outcome:** Never bounced to authorize login just for touching another signal server.
- [ ] **1.1** Interview + implement provision-secret ensure on restore (see `04`, user story options AD; recommend A).
- [ ] **1.2** Harden `auth_required` / `auth_error` home vs foreign handling (no false `SESSION_EXPIRED`).
- [ ] **1.3** Offline endpoints never navigate authorize (regression test).
- [ ] **1.4** Prove J4 + restart path (`02`).
**Depends on:** Wave 0.
**Pack:** `04`, story `agents-docs/user-stories/silent-cross-signal-server-auth.md`.
---
## Wave 2 — Presence identify/join integrity (P0)
**User outcome:** When you open a room, others see you; you receive live chat/voice roster.
- [ ] **2.1** Verify `getIdentifyCredentialsForSignalUrl` store fallback on every reconnect path.
- [ ] **2.2** Ensure room connect always identify → join with **actor** id for `sourceUrl`.
- [ ] **2.3** Alias canonicalization / cold-start health wait still correct.
- [ ] **2.4** Prove two-client presence + live `chat_message` after signal restart.
**Depends on:** Wave 1 (foreign rooms).
**Pack:** `05`, `08`.
---
## Wave 3 — Cross-signal outbound identity (P0)
**User outcome:** Calls ring the real person or show a clear error; DMs stay one thread.
- [ ] **3.1** Implement outbound routable recipient selection + unreachable UX (`09`, lesson L2).
- [ ] **3.2** Implement DM conversation canonicalize + merge (`09`, lesson L3).
- [ ] **3.3** Relabel/fix LESSONS so examples match shipped symbols.
- [ ] **3.4** Prove cross-home call + DM reply visibility.
**Depends on:** Wave 1. Can parallelize lightly with Wave 2 if staffing allows.
**Pack:** `09`.
---
## Wave 4 — Voice negotiation & routing (P0)
**User outcome:** Bidirectional voice in shared channels, including cross-home users; reconnect either works or errors visibly.
- [ ] **4.1** Per-signal-url local actor id for initiator election + polite peer (`06` failure 1).
- [ ] **4.2** Voice allow-list includes all peer-map aliases.
- [ ] **4.3** After reconnect budget: user-visible failure + retry action.
- [ ] **4.4** Decide TURN product stance (document vs defaults).
- [ ] **4.5** Prove same-home + cross-home voice; takeover; blip recovery.
**Depends on:** Waves 12; benefits from 3.
**Pack:** `06`.
---
## Wave 5 — Data channel recovery honesty (P0/P1)
**User outcome:** Control plane self-heals; chat history catch-up resumes; voice doesnt mysteriously die without explanation.
- [ ] **5.1** Interview: soft replace (A) vs full rebuild + UX/resync (B) (`07`).
- [ ] **5.2** Implement choice; force inventory/resync on DC reopen.
- [ ] **5.3** Align `realtime/README.md` + `voice-webrtc.md` with code (`10` L1).
- [ ] **5.4** Prove DC force-close recovery + no silent abandon.
**Depends on:** Wave 4 preferred (so voice metrics make sense).
**Pack:** `07`, `08`.
---
## Wave 6 — Messaging catch-up & attachments (P1)
**User outcome:** Late joiners get history; images/files dont stick on “Waiting…”.
- [ ] **6.1** Verify/fix inventory trigger on peer + DC repair.
- [ ] **6.2** Re-verify attachment announce/bind re-queue invariants.
- [ ] **6.3** Fix chat README inventory cap (`10` L6).
- [ ] **6.4** Prove late-join history + image sync under reorder stress.
**Depends on:** Waves 2 + 5.
**Pack:** `08`.
---
## Wave 7 — Doc & contract cleanup (P2, continuous)
- [ ] **7.1** Fix auth domain README paths (`10` L5).
- [ ] **7.2** Deprecate or fix `signaling-contracts.ts` warning banner.
- [ ] **7.3** Sync domain READMEs that oversimplify multi-credential auth.
- [ ] **7.4** Clear or refresh `agents-docs/HANDOFF.md` after each finished wave.
---
## Suggested staffing (multiple agents)
| Agent | Wave |
|-------|------|
| Auth agent | 1 |
| Realtime/signaling agent | 2, 5 |
| Identity/DM/call agent | 3 |
| Voice agent | 4 |
| Chat/attachments agent | 6 |
| Docs agent | 7 (or same PR as each fix) |
Do **not** start Wave 4 “TURN tuning” before Waves 13 — it wastes time on NAT while identity is wrong.
---
## Definition of “emergency over”
All of the following true on a two-signal-server manual matrix:
1. No spurious authorize/login while home session valid.
2. Presence mutual in shared rooms.
3. Bidirectional voice same-home and cross-home.
4. Live text + catch-up history.
5. Cross-home DM + call succeed or fail loudly.
6. DC/WS blip recovers or shows retry UI within ~1 minute.
7. Docs for recovery/identity match code (`10` critical lies cleared).
+129
View File
@@ -0,0 +1,129 @@
# 12 — Agent work packets (copy into new chats)
> Each packet is one new chat. Attach `@emergency-fix/00-README.md` plus the listed files.
> Say: **continue this packet; interview before implement; do not expand scope.**
---
## Packet A — Silent foreign auth
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/04-auth-login-bugs.md`
- `@emergency-fix/11-fix-priority-plan.md` (Wave 1)
- `@agents-docs/user-stories/silent-cross-signal-server-auth.md`
**Goal:** Wave 1 — never show authorize login for normal foreign joins when home session is valid.
**Out of scope:** Voice negotiation, DM canonicalize, server password hashing changes.
**First message expectation:** Short interview (options AD from user story); wait.
---
## Packet B — Presence / identify-join
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/05-signaling-multi-server.md`
- `@emergency-fix/08-messaging-visibility.md` (membership section)
- `@emergency-fix/11-fix-priority-plan.md` (Wave 2)
**Goal:** Mutual presence + live chat broadcast after connect/reconnect; foreign actor id on join.
**Depends:** Packet A ideally done (or mock credentials present).
**Out of scope:** Soft DC replace, TURN.
---
## Packet C — Outbound call + DM identity
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/09-identity-cross-signal.md`
- `@emergency-fix/10-code-lies-doc-debt.md` (L2, L3)
- `@emergency-fix/11-fix-priority-plan.md` (Wave 3)
**Goal:** Routable outbound `targetUserId`; unreachable UX; single DM thread across aliases; fix phantom LESSON references when shipping.
**Out of scope:** Auth provision secret (Packet A); full voice ICE redesign.
**Note:** LESSONS claim these fixes already exist — **they do not**. Implement, dont search forever.
---
## Packet D — Voice initiator & routing
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/06-voice-webrtc-bugs.md`
- `@emergency-fix/03-architecture-map.md`
- `@emergency-fix/11-fix-priority-plan.md` (Wave 4)
**Goal:** Cross-home bidirectional voice; per-signal actor initiator election; reconnect UX; TURN product decision.
**Depends:** A + B strongly; C helpful.
**Out of scope:** Rewriting entire PeerConnectionManager; server relay policy changes unless proven necessary (ask).
---
## Packet E — Data channel recovery
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/07-data-channel-drops.md`
- `@emergency-fix/10-code-lies-doc-debt.md` (L1)
- `@emergency-fix/11-fix-priority-plan.md` (Wave 5)
**Goal:** Honest recovery (soft replace **or** documented full rebuild + UX + forced resync); docs match code.
**Interview required:** Option A vs B in `07`.
---
## Packet F — Messaging catch-up & attachments
**Attach**
- `@emergency-fix/00-README.md`
- `@emergency-fix/08-messaging-visibility.md`
- `@emergency-fix/11-fix-priority-plan.md` (Wave 6)
- Optional: `agents-docs/features/messaging.md`, `agents-docs/features/attachments.md`
**Goal:** Late-join history; attachment waiting stuck fixed under reorder; README inventory cap corrected.
**Depends:** B + E.
---
## Packet G — Doc debt sweep
**Attach**
- `@emergency-fix/10-code-lies-doc-debt.md`
- `@emergency-fix/11-fix-priority-plan.md` (Wave 7)
**Goal:** Clear remaining L5L7 and any lies left after AF; no behavior changes unless a one-line comment/doc only.
---
## Handoff one-liner templates
After finishing a packet, overwrite `agents-docs/HANDOFF.md` and ask the user to start a new chat with:
```text
Continue from @agents-docs/HANDOFF.md and @emergency-fix/11-fix-priority-plan.md — next unchecked wave only.
```
Or jump packets:
```text
Start @emergency-fix/12-agent-work-packets.md Packet C — interview before implement.
```
@@ -0,0 +1,485 @@
# Validated emergency findings — Fable 5 implementation handoff
**Date:** 2026-08-12
**Scope:** `emergency-fix/`, `emergency-fix/e2e-failures/`, Angular client, targeted Electron/auth bridge, signaling server, and relevant Playwright tests.
**Method:** Read-only code and test audit. No product code was changed and the E2E suite was not rerun for this report.
## Executive verdict
The emergency pack is directionally correct, but it mixes active defects, already-shipped mitigations, hypotheses, and documentation debt. The current app is **not yet demonstrated to be Discord-reliable** despite a green `65/65` Playwright run.
The highest-confidence failure chain is:
1. Restored sessions do not ensure a provision secret.
2. A foreign signal credential cannot be created.
3. The user is sent to authorize login or never identifies/joins.
4. Presence is missing, so WebRTC discovery and WebSocket chat fallback fail.
5. When peers do connect, negotiation still compares home ids against foreign actor ids.
6. A data-channel failure tears down the entire peer connection, including media.
7. Reconnect attempts can be exhausted while signaling is offline, then stop silently.
Independent P0/P1 defects also exist in direct-message identity, call delivery, and message synchronization:
- inbound DMs trust an actor-id conversation id and can fork a second local thread;
- an outbound call enters local voice state before delivery is known, and delivery failure is ignored;
- a timed-out inventory cycle is marked “clean” without proving convergence, delaying the next poll for 15 minutes.
## Acceptance target
This emergency is not complete until two users with different home signal servers can:
- join the same foreign-hosted community without another login prompt;
- see each other online and in the correct voice channel;
- exchange live and catch-up text messages in both directions;
- place and answer a private call with bidirectional audio;
- keep one DM thread for the same human across actor aliases;
- transfer a file and verify received bytes;
- use camera and screen share while voice remains healthy;
- recover from a signal-server restart and a data-channel failure, or receive a visible retry action within one minute.
## Finding classification
- **Confirmed:** direct current-code mechanism.
- **Partial:** some mechanism exists, but the report overstates or misattributes it.
- **Mitigated:** code already contains the reported fix; retain as a regression test.
- **Unproven:** plausible but needs reproduction evidence.
## Confirmed findings
### F1 — Restored sessions can lack a provision secret
**Severity:** P0
**User symptom:** A logged-in user opens a foreign room or invite and is shown `/login?mode=authorize`, or remains invisible on that signal server.
`loadCurrentUserSuccess` migrates the home credential and immediately attempts foreign provisioning:
- [`users.effects.ts`](../toju-app/src/app/store/users/users.effects.ts), lines 199216
The restore path does **not** call `ensureHomeProvisionSecret`. That call currently exists only when storage is prepared from a fresh login response:
- [`users.effects.ts`](../toju-app/src/app/store/users/users.effects.ts), lines 288306
Provisioning exits when the secret is absent:
- [`signal-server-auth.service.ts`](../toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts), lines 154168
The web store uses `sessionStorage`, so a new tab/session can lose the secret even while other persisted identity data remains. Electron storage is durable, but old installs or cleared user data can still have no secret.
**Required fix:** ensure or deliberately recover the home provision secret before any restore-time or join-time foreign provision. Do not silently swallow restore provisioning errors.
**Important design caveat:** generating a new secret cannot authenticate foreign accounts created with a lost old secret. The implementation must define recovery for that case instead of looping register/login forever.
### F2 — Missing foreign credentials block presence and chat fallback
**Severity:** P0
**User symptom:** The room opens locally, but other users do not see the user; chat is sender-only; voice is empty.
The room connection refuses to proceed without a credential. The signaling transport skips identify if credentials cannot be resolved. The server rejects all non-keepalive, non-identify messages from unauthenticated connections.
Live WebSocket channel chat is also membership-gated:
- [`server/src/websocket/handler.ts`](../server/src/websocket/handler.ts), lines 539552
Therefore auth/presence must be repaired before treating chat or voice as isolated transport bugs.
**Required fix:** prove the complete sequence `credential -> identify(actor id) -> join_server -> mutual roster`, including reconnect.
### F3 — WebRTC initiator and glare logic use the wrong identity space
**Severity:** P0
**User symptom:** Cross-home peers connect intermittently, both offer, both wait, or voice remains one-way/connecting.
Per-signal credentials exist through `getIdentifyCredentialsForSignalUrl`, but the generic getter prefers the home credential:
- [`signaling-transport-handler.ts`](../toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts), lines 3571
The peer manager and incoming signaling handler receive the generic home-biased getter:
- [`realtime-session.service.ts`](../toju-app/src/app/infrastructure/realtime/realtime-session.service.ts), lines 198208 and 232234
Offer collision handling compares that id with the remote signal actor id:
- [`negotiation.ts`](../toju-app/src/app/infrastructure/realtime/peer-connection-manager/connection/negotiation.ts), lines 106126
Initiator election performs the same lexical comparison:
- [`signaling-message-handler.ts`](../toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts), lines 528535
**Required fix:** carry connection/signal scope into initiator election, polite-peer handling, reconnect, and P2P voice-state payloads. Both peers must compare actor ids from the same signal URL.
### F4 — Reconnect budget is consumed while signaling is unavailable
**Severity:** P0
**User symptom:** A signal outage lasts about one minute; signaling returns, but the peer mesh does not retry and no error is shown.
Each timer tick increments `reconnectAttempts` before checking signaling connectivity. At the limit, the timer and tracker are deleted:
- [`peer-recovery.ts`](../toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts), lines 282321
This means the client can spend all 12 attempts doing no actual reconnection work.
**Required fix:** do not consume an attempt until an offer/reconnect is actually attempted. On exhaustion, publish an explicit failed state with a user-visible Retry action. A later presence or signaling recovery event must be able to re-arm repair.
### F5 — A closed data channel tears down media
**Severity:** P0
**User symptom:** Voice, camera, or screen share drops when chat/file control transport fails.
The active recovery path calls `removePeer`, then creates a new peer transport:
- [`peer-recovery.ts`](../toju-app/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts), lines 208235
The realtime README claims the initiator creates a replacement data channel on the existing `RTCPeerConnection`:
- [`realtime/README.md`](../toju-app/src/app/infrastructure/realtime/README.md), line 263
That claim is false for the current recovery path. `replaceDataChannel` exists but is not the implemented repair.
**Required decision:**
- **Recommended emergency step:** keep full rebuild initially, but force resync, preserve/reapply media state, expose progress/failure, and fix the docs.
- **Follow-up reliability step:** implement a true soft data-channel replacement on a healthy peer connection, with full rebuild as fallback.
### F6 — Direct messages can fork by home id versus actor id
**Severity:** P0
**User symptom:** Two threads represent the same person; a reply lands in the thread the sender is not viewing.
The pair id is a literal sorted pair of supplied ids:
- [`direct-message.logic.ts`](../toju-app/src/app/domains/direct-message/domain/logic/direct-message.logic.ts), lines 1721
Inbound handling trusts `payload.message.conversationId` before deriving a local id and performs no alias merge:
- [`direct-message.service.ts`](../toju-app/src/app/domains/direct-message/application/services/direct-message.service.ts), lines 511541
The lesson symbols `resolveDirectConversationId` and `mergeAliasDirectConversations` do not exist in the product tree.
**Required fix:** define a canonical local human identity, remap inbound actor ids, merge duplicate conversations transactionally, and preserve messages/unread/status ordering.
### F7 — Outbound private calls can fail silently after local join
**Severity:** P0
**User symptom:** Caller sees “In Voice”; callee never rings.
`startCall` joins the call locally before sending the ring:
- [`direct-call.service.ts`](../toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts), lines 220235
`sendCallEvent` does not await or inspect delivery:
- [`direct-call.service.ts`](../toju-app/src/app/domains/direct-call/application/services/direct-call.service.ts), lines 759773
Candidate expansion in `PeerDeliveryService` helps when a matching NgRx user alias exists, but there is no complete credential/presence-based routable-id selection and no unreachable UX.
**Required fix:** resolve the connected actor id before committing the caller to the session, return a delivery result, and transition to `recipientUnreachable`/Retry when no route accepts the ring.
### F8 — Message synchronization can falsely become “clean”
**Severity:** P1, potentially P0 for long missing-history windows
**User symptom:** Live chat works, but old messages remain missing for up to 15 minutes.
Periodic sync sends inventory requests and dispatches `startSync`:
- [`messages-sync.effects.ts`](../toju-app/src/app/store/messages/messages-sync.effects.ts), lines 166213
Five seconds later, if the reducer still says syncing, the effect unconditionally sets `lastSyncClean = true` and dispatches `syncComplete`:
- [`messages-sync.effects.ts`](../toju-app/src/app/store/messages/messages-sync.effects.ts), lines 215232
No evidence of an empty inventory result is required. The next interval can therefore change from 10 seconds to 15 minutes after timeout rather than convergence.
**Required fix:** model an inventory round explicitly. Mark clean only when all expected peers have replied and no missing ids remain. Timeouts and newly received messages must remain dirty.
### F9 — Live chat fallback and history repair are separate
**Severity:** P1
**User symptom:** New text arrives while older text, edits, files, and emoji remain absent.
The WebSocket `chat_message` path handles live channel messages for joined members. Inventory and sync batches are data-channel-only. `message-revision` has no equivalent WebSocket fallback.
`onPeerConnected` kicks inventory:
- [`messages-sync.effects.ts`](../toju-app/src/app/store/messages/messages-sync.effects.ts), lines 6494
That signal can occur at peer-connection state `connected` before the data channel is open; a second data-channel-open signal usually retries, but there is no explicit durable “control channel reopened and inventory completed” state.
**Required fix:** trigger and track reconciliation from data-channel open, not only generic peer connected. Queue failed inventory sends and retry on channel open.
### F10 — STUN-only defaults cannot guarantee Discord-like reachability
**Severity:** P1 product limitation
**User symptom:** Voice works on friendly networks but fails on symmetric NAT, enterprise Wi-Fi, carrier networks, or restrictive firewalls.
TURN can be configured by the user, but no managed/default TURN service is supplied. Localhost E2E cannot validate NAT traversal.
**Required product decision:** operate a credentialed TURN service with expiry/abuse controls, or explicitly reject the requirement that voice “always works.” Documentation alone does not satisfy the stated target.
## Claims that should not be treated as active defects
### Identify cache fallback — mitigated
`getIdentifyCredentialsForSignalUrl` falls back to the credential store when the per-URL cache is empty:
- [`signaling-transport-handler.ts`](../toju-app/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts), lines 4771
Keep this as a regression invariant. Add a focused spec; do not rewrite this path without a failing reproduction.
### Join-before-identify race — substantially mitigated
Client reconnect sends identify before rejoin, and the server serializes message handling per connection. The remaining P0 case is missing credentials, not evidence that server serialization is currently broken.
### Shared `clientInstanceId` across tabs — fixed
Current code uses `sessionStorage` and clears the legacy local-storage value. Retain the existing regression test.
### Multi-signal `user_left` teardown — mitigated
Current client/server paths carry server membership information and preserve peers still shared elsewhere. This needs multi-signal integration coverage, but the pack should not call it a confirmed current failure.
### Voice allow-list alias miss — partial
The voice playback service already collects `id`, `oderId`, and `peerId`. More likely active failures are stale/missing `voiceState`, a peer key absent from all three aliases, or the broader home/actor negotiation mismatch. Instrument before changing routing filters.
### “No retry after abandon” — overstated
Presence fallback timers can create a later offer. The confirmed defect is that the explicit reconnect tracker is silently discarded and signaling-down ticks consume its budget.
### Attachment announce/message race — fixed, retain
The incoming message handler re-queues auto-download after binding message to room, with unit coverage. Add reorder/large-file E2E rather than reimplementing the fix.
## E2E baseline assessment
The archived run is internally consistent:
- 65 passed
- 0 failed
- 0 flaky
- 0 skipped
- about 13.4 minutes
Sources:
- [`e2e-failures/README.md`](e2e-failures/README.md)
- [`e2e-failures/00-summary.md`](e2e-failures/00-summary.md)
- `e2e-failures/parsed-report.json`
- `e2e-failures/full-run.log`
### What it proves
- Scripted flows passed once in Chromium with retries disabled.
- The Angular development server, ephemeral localhost signaling servers, fake mic/camera, and synthetic screen capture can support the covered scenarios.
- Strong existing scenarios include two-user voice, eight-user local multi-signal voice, synthetic data-channel recovery, direct-call answer/audio on a same-route setup, chat sync, and several attachment regressions.
### What it does not prove
The Playwright configuration runs only Chromium against `ng serve`:
- [`e2e/playwright.config.ts`](../e2e/playwright.config.ts), lines 339
It does not run Electron, a packaged production build, real desktop safeStorage/SQLite behavior, real screen capture, real TURN/NAT, sleep/wake, or a production signal deployment.
The cross-signal auth test adds the second endpoint manually before checking provision:
- [`multi-signal-server-auth.spec.ts`](../e2e/tests/auth/multi-signal-server-auth.spec.ts), lines 2070
It does not cover restore with a missing secret or joining an unknown foreign invite.
The cross-signal call test proves only that the callee sees a ring:
- [`dm-header-call-ring.spec.ts`](../e2e/tests/voice/dm-header-call-ring.spec.ts), lines 92184
It does not answer the call, verify audio, exercise people-card home-id routing, or prove one DM thread.
The offline DM test proves only local `QUEUED` state:
- [`dm-flow.spec.ts`](../e2e/tests/chat/dm-flow.spec.ts), lines 1536
It never reconnects or proves recipient delivery.
The mesh helper permits fewer than the calculated expected connections:
- [`signal-manager.ts`](../e2e/helpers/signal-manager.ts), lines 2850
This can let a partial dual-signal mesh pass. Debug inspection also depends on Angular's dev-only `window.ng`.
The data-channel test proves that audio eventually resumes after a synthetic close:
- [`data-channel-recovery.spec.ts`](../e2e/tests/voice/data-channel-recovery.spec.ts), lines 2754
It does not prove uninterrupted audio, which is consistent with the current full peer teardown.
## Revised implementation order
Do not tune codecs, ICE timers, or TURN before identity and membership are correct.
### Packet 0 — Reproduction and observability
**Goal:** produce one deterministic two-signal matrix and enough correlated data to prove each later fix.
Log in debug builds:
- home user id;
- credential actor id per normalized signal URL;
- room id/source URL;
- peer-map key and peer signal URL;
- initiator/polite decision inputs;
- call `targetUserId` and delivery outcome;
- DM incoming and resolved conversation ids;
- data-channel generation and reconnect attempt reason.
Do not log tokens, passwords, provision secrets, SDP bodies, or message contents.
### Packet 1 — Restore-safe silent foreign auth
**Primary files:**
- `toju-app/src/app/store/users/users.effects.ts`
- `toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts`
- `signal-server-authorize.service.ts`
- provision-secret stores; targeted Electron bridge only if the persistence contract changes
**Tests:**
- restored home session + absent secret;
- restored Electron session;
- web new-tab behavior;
- old foreign account whose previous secret is lost;
- offline/unknown endpoint never opens authorize;
- foreign `auth_error` does not expire the valid home session.
### Packet 2 — Identify/join/presence integrity
**Primary files:**
- `room-signaling-connection.ts`
- `signaling-transport-handler.ts`
- `signaling.manager.ts`
- targeted `server/src/websocket/handler.ts` only if a failing test proves a server change is needed
**Tests:**
- credential-store fallback on a fresh socket;
- identify is first authenticated message before join;
- signal-server restart restores both users to the roster;
- leaving one signal scope does not remove a peer shared through another.
### Packet 3 — Cross-signal identity for calls and DMs
**Primary files:**
- `domains/direct-message/**`
- `domains/direct-call/**`
- `peer-delivery.service.ts`
- authentication credential/alias readers
**Tests:**
- candidate collection from home id, actor id, peer id, and known presence routes;
- unreachable call never leaves caller silently in voice;
- merge two alias conversations without message/status loss;
- cross-signal DM reply remains in one thread;
- cross-signal call rings, answers, and carries bidirectional audio.
### Packet 4 — Per-signal WebRTC identity
**Primary files:**
- `realtime-session.service.ts`
- `signaling-message-handler.ts`
- `connection/negotiation.ts`
- `recovery/peer-recovery.ts`
- voice-state data-channel payload construction
**Tests:**
- home-id/actor-id matrix elects exactly one initiator;
- polite-peer collision decision uses the same scoped ids;
- same-home and cross-home voice are bidirectional;
- stale voice state cannot leave a connected peer permanently muted without recovery.
### Packet 5 — Honest data-channel and reconnect recovery
**Primary files:**
- `recovery/peer-recovery.ts`
- `messaging/data-channel.ts`
- `peer-connection.manager.ts`
- user-visible connectivity state/components
**Tests:**
- signaling-down ticks do not consume reconnect attempts;
- max real attempts exposes Retry;
- Retry re-arms recovery;
- data-channel reopen forces inventory;
- media state is reapplied after full rebuild;
- if soft replacement is implemented, audio remains continuously flowing.
### Packet 6 — Message convergence and file integrity
**Primary files:**
- `store/messages/messages-sync.effects.ts`
- message sync reducers/rules/handlers
- attachment transfer only where a failing test identifies a gap
**Tests:**
- timeout remains dirty;
- only complete, empty inventory rounds select the 15-minute cadence;
- late join catches up;
- edit/delete converge after DC recovery;
- offline DM reconnects and delivers;
- large-file download SHA-256 and size match the source;
- attachment announce/message order is stressed in both channel and DM paths.
### Packet 7 — Production reliability proof
Add separate suites rather than weakening existing fast PR coverage:
- Electron smoke: login restore, foreign provision, text, file, voice;
- signal restart during active voice and DM;
- TURN-backed relay-only test;
- sleep/wake or network-interface-change test;
- 30-minute scheduled voice/screen-share soak;
- exact mesh assertions where topology guarantees the count.
## Documentation corrections required with fixes
1. Replace the false soft-data-channel paragraph in `realtime/README.md` unless soft replacement ships.
2. Relabel the LESSONS outbound-call and DM-canonicalization entries as unfinished specifications until real symbols and tests exist.
3. Correct authentication README endpoints from `/api/auth/*` to `/api/users/*`.
4. Correct chat README inventory cap from 1,000 to 20,000.
5. Mark `shared-kernel/signaling-contracts.ts` non-authoritative or align it with live wire names.
6. Correct `e2e/CONTEXT.md` if it claims Electron coverage; the current suite is browser + `ng serve`.
## Decisions required from the product owner before implementation
1. **Lost provision secret recovery**
- Recommended: automatically create a secret when none has ever existed; when an existing foreign account rejects the new secret, show a targeted per-server recovery action instead of a generic login loop.
2. **Web secret durability**
- Recommended emergency default: keep session-scoped storage for security, but make the new-tab limitation explicit and recover without misclassifying home logout.
3. **Data-channel strategy**
- Recommended: ship visible full-rebuild recovery + forced resync first, then soft replacement as a measured follow-up.
4. **TURN**
- Recommended for the stated Discord-quality goal: managed credentialed TURN, not user-only configuration.
5. **History availability**
- The signal server stores no chat history. Decide whether “offline users always recover history” may depend on another online client, or whether an encrypted durable mailbox/history service is required.
6. **Presence semantics**
- Define online/idle/DND/offline and stale timeout behavior. Current emergency tests mostly prove connectivity, not Discord-like status semantics.
## Definition of done for Fable 5
For every packet:
1. Start from a behavior-level failing test or deterministic reproduction.
2. Change only the packet's owned surfaces.
3. Record actor ids and signal scope in test diagnostics without secrets.
4. Run focused unit/integration tests, then the relevant Playwright subset.
5. Run lint/build for touched packages.
6. Update lying docs in the same change.
7. Do not mark complete from unit-green alone; attach the user-visible proof.
The green 65-test archive is a useful baseline, not release certification. Release confidence requires the two-signal cross-home matrix, real restart recovery, Electron coverage, and TURN/NAT validation described above.
+19
View File
@@ -0,0 +1,19 @@
# E2E summary
- Run: 2026-08-12T10:58:08Z → 2026-08-12T11:11:34Z UTC
- **65 passed / 0 failed / 0 flaky / 0 timeouts**
- Wall: ~13.4 min
## Errors
None.
## Soft signals (passed but slow)
- 128.2s — `voice/mixed-signal-config-voice.spec.ts` — 8 users with different signal configs can voice, mute, deafen, and chat concurrently
- 125.1s — `voice/multi-signal-eight-user-voice.spec.ts` — keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds
- 53.4s — `auth/user-session-data-isolation.spec.ts` — gives a new user a blank slate and restores only that user local data after account switches
- 33.7s — `chat/chat-message-features.spec.ts` — shows per-server channel lists on first saved-server click
- 30.3s — `chat/multi-client-chat-sync.spec.ts` — syncs messages between same-user devices and late-joining users after offline gaps
See `README.md` for full duration table.
+152
View File
@@ -0,0 +1,152 @@
# E2E Playwright run — emergency-fix baseline
- **Started (UTC):** 2026-08-12T10:58:08Z
- **Finished (UTC):** 2026-08-12T11:11:34Z
- **Wall duration:** 804.6s (~13.4 min)
- **Command:** `npm run test:e2e -- --reporter=list --reporter=json`
- **Result:** 65 passed, 0 failed, 0 flaky, 0 skipped
## Verdict
**No hard errors.** The suite completed green: 65/65 expected, 0 unexpected, 0 timeouts, 0 flakes.
There is nothing to triage as a failing assertion in this run. Slow cases below are the only signals worth watching if “timeouts shouldnt happen / app should be fast.”
## Errors / failures
_None in this run._
Stale note: `test-results/.last-run.json` and `test-results/html-report/` still show an old July failure; this run overrode reporters with list+json and did not refresh those artifacts.
## Slow tests (≥30s) — soft concern
| Duration | File | Test |
| --- | --- | --- |
| 128.2s | `voice/mixed-signal-config-voice.spec.ts` | 8 users with different signal configs can voice, mute, deafen, and chat concurrently |
| 125.1s | `voice/multi-signal-eight-user-voice.spec.ts` | keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds |
| 53.4s | `auth/user-session-data-isolation.spec.ts` | gives a new user a blank slate and restores only that user local data after account switches |
| 33.7s | `chat/chat-message-features.spec.ts` | shows per-server channel lists on first saved-server click |
| 30.3s | `chat/multi-client-chat-sync.spec.ts` | syncs messages between same-user devices and late-joining users after offline gaps |
These passed, but they are the closest thing to “timeout risk” in a green suite. Multi-user voice specs dominate wall time.
## Per-file totals
| File | Tests | Total | Max |
| --- | ---: | ---: | ---: |
| `voice/mixed-signal-config-voice.spec.ts` | 1 | 128.2s | 128.2s |
| `voice/multi-signal-eight-user-voice.spec.ts` | 1 | 125.1s | 125.1s |
| `chat/chat-message-features.spec.ts` | 9 | 84.2s | 33.7s |
| `auth/user-session-data-isolation.spec.ts` | 2 | 73.2s | 53.4s |
| `screen-share/screen-share.spec.ts` | 3 | 49.8s | 23.4s |
| `voice/direct-call.spec.ts` | 3 | 31.8s | 17.5s |
| `chat/multi-client-chat-sync.spec.ts` | 1 | 30.3s | 30.3s |
| `plugins/plugin-api-two-users.spec.ts` | 1 | 27.4s | 27.4s |
| `chat/notifications.spec.ts` | 2 | 25.6s | 20.9s |
| `voice/data-channel-recovery.spec.ts` | 2 | 22.1s | 12.1s |
| `chat/profile-avatar-sync.spec.ts` | 2 | 21.5s | 11.9s |
| `settings/connectivity-warning.spec.ts` | 1 | 21.3s | 21.3s |
| `voice/voice-full-journey.spec.ts` | 1 | 21.1s | 21.1s |
| `chat/server-icon-sync.spec.ts` | 1 | 14.2s | 14.2s |
| `chat/dm-flow.spec.ts` | 3 | 11.8s | 4.0s |
| `settings/stun-turn-fallback.spec.ts` | 1 | 11.5s | 11.5s |
| `auth/login-return-url.spec.ts` | 3 | 9.4s | 3.5s |
| `voice/dm-header-call-ring.spec.ts` | 2 | 9.1s | 4.8s |
| `auth/multi-device-session.spec.ts` | 1 | 8.6s | 8.6s |
| `chat/multi-device-attachment-sharing.spec.ts` | 2 | 8.6s | 4.3s |
| `voice/voice-mute-state-reset.spec.ts` | 1 | 6.9s | 6.9s |
| `servers/server-discovery-default.spec.ts` | 2 | 6.3s | 3.1s |
| `settings/ice-server-settings.spec.ts` | 2 | 6.2s | 3.9s |
| `chat/large-generic-file-transfer.spec.ts` | 1 | 5.7s | 5.7s |
| `plugins/plugin-manager-ui.spec.ts` | 1 | 4.6s | 4.6s |
| `chat/custom-emoji-user-binding.spec.ts` | 1 | 3.9s | 3.9s |
| `auth/multi-signal-server-auth.spec.ts` | 1 | 3.8s | 3.8s |
| `auth/offline-signal-server-no-login-loop.spec.ts` | 1 | 3.7s | 3.7s |
| `chat/local-attachment-persistence.spec.ts` | 1 | 2.8s | 2.8s |
| `chat/attachment-only-message-grouping.spec.ts` | 1 | 2.5s | 2.5s |
| `mobile/mobile-login-on-startup.spec.ts` | 2 | 2.4s | 1.2s |
| `chat/multi-image-gallery.spec.ts` | 1 | 2.3s | 2.3s |
| `plugins/plugin-support-api.spec.ts` | 1 | 1.9s | 1.9s |
| `mobile/mobile-settings-logout.spec.ts` | 1 | 1.7s | 1.7s |
| `mobile/android-app-icon.spec.ts` | 6 | 0.0s | 0.0s |
## All tests by duration
| Duration | Status | File | Test |
| --- | --- | --- | --- |
| 128.2s | passed | `voice/mixed-signal-config-voice.spec.ts` | 8 users with different signal configs can voice, mute, deafen, and chat concurrently |
| 125.1s | passed | `voice/multi-signal-eight-user-voice.spec.ts` | keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds |
| 53.4s | passed | `auth/user-session-data-isolation.spec.ts` | gives a new user a blank slate and restores only that user local data after account switches |
| 33.7s | passed | `chat/chat-message-features.spec.ts` | shows per-server channel lists on first saved-server click |
| 30.3s | passed | `chat/multi-client-chat-sync.spec.ts` | syncs messages between same-user devices and late-joining users after offline gaps |
| 27.4s | passed | `plugins/plugin-api-two-users.spec.ts` | runs chat, embed, soundboard, and profile APIs between two users |
| 23.4s | passed | `screen-share/screen-share.spec.ts` | screen share connection stays stable for 10+ seconds |
| 21.3s | passed | `settings/connectivity-warning.spec.ts` | shows warning icon when a peer loses all connections |
| 21.1s | passed | `voice/voice-full-journey.spec.ts` | two users register, create server, join voice, and stay connected 10+ seconds with audio |
| 20.9s | passed | `chat/notifications.spec.ts` | keeps unread badges visible when a muted channel suppresses desktop popups |
| 19.7s | passed | `auth/user-session-data-isolation.spec.ts` | preserves a user saved rooms and local history across app restarts |
| 19.6s | passed | `chat/chat-message-features.spec.ts` | syncs messages in a newly created text channel |
| 17.5s | passed | `voice/direct-call.spec.ts` | two users can ring, answer, chat, see self voice indicators, and exchange audio |
| 15.4s | passed | `screen-share/screen-share.spec.ts` | single user screen share: video and audio flow to receiver, voice audio continues |
| 14.2s | passed | `chat/server-icon-sync.spec.ts` | loads the chat-server image for online, late-joining, restarted, and discovery users |
| 12.1s | passed | `voice/data-channel-recovery.spec.ts` | heals a three-user voice mesh when one client loses every data channel |
| 11.9s | passed | `chat/profile-avatar-sync.spec.ts` | syncs display name and description changes for online and late-joining users and persists after restart |
| 11.5s | passed | `settings/stun-turn-fallback.spec.ts` | users with different ICE configs can voice chat together |
| 11.1s | passed | `screen-share/screen-share.spec.ts` | multiple users screen share simultaneously |
| 10.0s | passed | `voice/data-channel-recovery.spec.ts` | keeps two users hearing each other after a data-channel error and close |
| 9.6s | passed | `chat/profile-avatar-sync.spec.ts` | syncs avatar changes for online and late-joining users and persists after restart |
| 8.9s | passed | `voice/direct-call.spec.ts` | keeps private-call audio flowing after the data channel closes |
| 8.6s | passed | `auth/multi-device-session.spec.ts` | covers identity, chat sync, typing exclusion, and voice exclusivity |
| 6.9s | passed | `voice/voice-mute-state-reset.spec.ts` | clears stale mute state after abrupt disconnect and voice rejoin |
| 5.7s | passed | `chat/large-generic-file-transfer.spec.ts` | browser receiver can request and download a generic file above the auto-save cap |
| 5.5s | passed | `chat/chat-message-features.spec.ts` | edits and removes messages for both users |
| 5.3s | passed | `voice/direct-call.spec.ts` | missing and ended private calls do not leave stale call controls behind |
| 4.8s | passed | `voice/dm-header-call-ring.spec.ts` | callee homed on another signal server is notified when called via their provisioned actor id |
| 4.8s | passed | `chat/notifications.spec.ts` | shows desktop notifications and unread badges for inactive channels |
| 4.6s | passed | `chat/chat-message-features.spec.ts` | sends KLIPY GIF messages with mocked API responses |
| 4.6s | passed | `plugins/plugin-manager-ui.spec.ts` | installs, grants, activates, and logs an all-API test plugin |
| 4.4s | passed | `chat/chat-message-features.spec.ts` | syncs image and file attachments between users |
| 4.4s | passed | `chat/chat-message-features.spec.ts` | syncs multi-chunk image attachments byte-identical between users |
| 4.3s | passed | `chat/multi-device-attachment-sharing.spec.ts` | only the uploading device claims "Shared from your device"; the second same-user device can request it |
| 4.3s | passed | `chat/chat-message-features.spec.ts` | renders link embeds for shared links |
| 4.3s | passed | `chat/multi-device-attachment-sharing.spec.ts` | relays file-announce metadata to a sibling device that is already online during upload |
| 4.2s | passed | `voice/dm-header-call-ring.spec.ts` | callee is notified when the caller starts the call from the DM chat header |
| 4.1s | passed | `chat/chat-message-features.spec.ts` | shows typing indicators to other users |
| 4.0s | passed | `chat/dm-flow.spec.ts` | delivers a live DM to the recipient conversation |
| 3.9s | passed | `chat/custom-emoji-user-binding.spec.ts` | a second user on the same client does not inherit the first user library |
| 3.9s | passed | `chat/dm-flow.spec.ts` | opens a DM from a user card and queues messages while offline |
| 3.9s | passed | `settings/ice-server-settings.spec.ts` | allows adding, removing, and reordering ICE servers |
| 3.8s | passed | `chat/dm-flow.spec.ts` | shows friend and message actions on the search people list |
| 3.8s | passed | `auth/multi-signal-server-auth.spec.ts` | auto-provisions a foreign signal server when a new endpoint is added |
| 3.7s | passed | `auth/offline-signal-server-no-login-loop.spec.ts` | does not redirect to authorize login after a foreign server goes offline |
| 3.6s | passed | `chat/chat-message-features.spec.ts` | shows local room history on first saved-server click |
| 3.5s | passed | `auth/login-return-url.spec.ts` | unwraps nested login returnUrl chains after successful login |
| 3.1s | passed | `servers/server-discovery-default.spec.ts` | a fresh account sees public servers in Popular Servers without searching |
| 3.1s | passed | `servers/server-discovery-default.spec.ts` | discovery falls back to the public listing when featured/trending routes 404 |
| 3.1s | passed | `auth/login-return-url.spec.ts` | redirects unauthenticated /servers visits to login and returns there after login |
| 2.8s | passed | `auth/login-return-url.spec.ts` | lets a returning user log back in after an expired session redirect |
| 2.8s | passed | `chat/local-attachment-persistence.spec.ts` | remembers sent image and file across a page reload with no peer connected |
| 2.5s | passed | `chat/attachment-only-message-grouping.spec.ts` | each caption-less attachment keeps its own message bubble and preview |
| 2.3s | passed | `settings/ice-server-settings.spec.ts` | validates TURN entries require credentials |
| 2.3s | passed | `chat/multi-image-gallery.spec.ts` | groups three images in one message bubble with a visible grid |
| 1.9s | passed | `plugins/plugin-support-api.spec.ts` | covers plugin requirement, event, data, and websocket APIs with the fixture plugin |
| 1.7s | passed | `mobile/mobile-settings-logout.spec.ts` | exposes logout in the settings menu on mobile viewports |
| 1.2s | passed | `mobile/mobile-login-on-startup.spec.ts` | greets a signed-out mobile visitor on /dashboard with the login screen |
| 1.2s | passed | `mobile/mobile-login-on-startup.spec.ts` | greets a signed-out mobile visitor on the app root with the login screen |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | renders the brand mark (white cat on a purple disc) in the launcher bitmap |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | renders the splash art as the brand mark centred on a purple field |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | insets the adaptive foreground so launcher masks do not clip the cat face |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | replaces every stock Capacitor placeholder with the brand asset |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | ships a launcher icon and splash for every required density |
| 0.0s | passed | `mobile/android-app-icon.spec.ts` | uses the brand purple as the adaptive-icon background |
## Files in this folder
| Path | Purpose |
| --- | --- |
| `README.md` | This summary |
| `00-summary.md` | Short status for handoff/skimming |
| `slow-tests.md` | Slow-test detail only |
| `parsed-report.json` | Machine-readable extract |
| `full-run.log` | Raw Playwright JSON report (+ npm banner) |
| `run-started.txt` / `run-finished.txt` | UTC timestamps |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,516 @@
{
"stats": {
"startTime": "2026-08-12T10:58:09.830Z",
"duration": 804598.4550000001,
"expected": 65,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"failures": [],
"timeouts": [],
"slow": [
{
"title": "8 users with different signal configs can voice, mute, deafen, and chat concurrently",
"file": "voice/mixed-signal-config-voice.spec.ts",
"status": "passed",
"duration": 128215,
"error": null,
"errors": [],
"timeoutHint": false
},
{
"title": "keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds",
"file": "voice/multi-signal-eight-user-voice.spec.ts",
"status": "passed",
"duration": 125136,
"error": null,
"errors": [],
"timeoutHint": false
},
{
"title": "gives a new user a blank slate and restores only that user local data after account switches",
"file": "auth/user-session-data-isolation.spec.ts",
"status": "passed",
"duration": 53448,
"error": null,
"errors": [],
"timeoutHint": false
},
{
"title": "shows per-server channel lists on first saved-server click",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 33665,
"error": null,
"errors": [],
"timeoutHint": false
},
{
"title": "syncs messages between same-user devices and late-joining users after offline gaps",
"file": "chat/multi-client-chat-sync.spec.ts",
"status": "passed",
"duration": 30290,
"error": null,
"errors": [],
"timeoutHint": false
}
],
"all": [
{
"title": "unwraps nested login returnUrl chains after successful login",
"file": "auth/login-return-url.spec.ts",
"status": "passed",
"duration": 3506,
"timeoutHint": false
},
{
"title": "redirects unauthenticated /servers visits to login and returns there after login",
"file": "auth/login-return-url.spec.ts",
"status": "passed",
"duration": 3088,
"timeoutHint": false
},
{
"title": "lets a returning user log back in after an expired session redirect",
"file": "auth/login-return-url.spec.ts",
"status": "passed",
"duration": 2811,
"timeoutHint": false
},
{
"title": "covers identity, chat sync, typing exclusion, and voice exclusivity",
"file": "auth/multi-device-session.spec.ts",
"status": "passed",
"duration": 8640,
"timeoutHint": false
},
{
"title": "auto-provisions a foreign signal server when a new endpoint is added",
"file": "auth/multi-signal-server-auth.spec.ts",
"status": "passed",
"duration": 3836,
"timeoutHint": false
},
{
"title": "does not redirect to authorize login after a foreign server goes offline",
"file": "auth/offline-signal-server-no-login-loop.spec.ts",
"status": "passed",
"duration": 3727,
"timeoutHint": false
},
{
"title": "preserves a user saved rooms and local history across app restarts",
"file": "auth/user-session-data-isolation.spec.ts",
"status": "passed",
"duration": 19725,
"timeoutHint": false
},
{
"title": "gives a new user a blank slate and restores only that user local data after account switches",
"file": "auth/user-session-data-isolation.spec.ts",
"status": "passed",
"duration": 53448,
"timeoutHint": false
},
{
"title": "each caption-less attachment keeps its own message bubble and preview",
"file": "chat/attachment-only-message-grouping.spec.ts",
"status": "passed",
"duration": 2507,
"timeoutHint": false
},
{
"title": "shows per-server channel lists on first saved-server click",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 33665,
"timeoutHint": false
},
{
"title": "shows local room history on first saved-server click",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 3598,
"timeoutHint": false
},
{
"title": "syncs messages in a newly created text channel",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 19613,
"timeoutHint": false
},
{
"title": "shows typing indicators to other users",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4090,
"timeoutHint": false
},
{
"title": "edits and removes messages for both users",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 5534,
"timeoutHint": false
},
{
"title": "syncs image and file attachments between users",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4442,
"timeoutHint": false
},
{
"title": "syncs multi-chunk image attachments byte-identical between users",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4369,
"timeoutHint": false
},
{
"title": "renders link embeds for shared links",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4303,
"timeoutHint": false
},
{
"title": "sends KLIPY GIF messages with mocked API responses",
"file": "chat/chat-message-features.spec.ts",
"status": "passed",
"duration": 4610,
"timeoutHint": false
},
{
"title": "a second user on the same client does not inherit the first user library",
"file": "chat/custom-emoji-user-binding.spec.ts",
"status": "passed",
"duration": 3929,
"timeoutHint": false
},
{
"title": "opens a DM from a user card and queues messages while offline",
"file": "chat/dm-flow.spec.ts",
"status": "passed",
"duration": 3882,
"timeoutHint": false
},
{
"title": "delivers a live DM to the recipient conversation",
"file": "chat/dm-flow.spec.ts",
"status": "passed",
"duration": 4024,
"timeoutHint": false
},
{
"title": "shows friend and message actions on the search people list",
"file": "chat/dm-flow.spec.ts",
"status": "passed",
"duration": 3849,
"timeoutHint": false
},
{
"title": "browser receiver can request and download a generic file above the auto-save cap",
"file": "chat/large-generic-file-transfer.spec.ts",
"status": "passed",
"duration": 5690,
"timeoutHint": false
},
{
"title": "remembers sent image and file across a page reload with no peer connected",
"file": "chat/local-attachment-persistence.spec.ts",
"status": "passed",
"duration": 2793,
"timeoutHint": false
},
{
"title": "syncs messages between same-user devices and late-joining users after offline gaps",
"file": "chat/multi-client-chat-sync.spec.ts",
"status": "passed",
"duration": 30290,
"timeoutHint": false
},
{
"title": "only the uploading device claims \"Shared from your device\"; the second same-user device can request it",
"file": "chat/multi-device-attachment-sharing.spec.ts",
"status": "passed",
"duration": 4333,
"timeoutHint": false
},
{
"title": "relays file-announce metadata to a sibling device that is already online during upload",
"file": "chat/multi-device-attachment-sharing.spec.ts",
"status": "passed",
"duration": 4266,
"timeoutHint": false
},
{
"title": "groups three images in one message bubble with a visible grid",
"file": "chat/multi-image-gallery.spec.ts",
"status": "passed",
"duration": 2294,
"timeoutHint": false
},
{
"title": "shows desktop notifications and unread badges for inactive channels",
"file": "chat/notifications.spec.ts",
"status": "passed",
"duration": 4758,
"timeoutHint": false
},
{
"title": "keeps unread badges visible when a muted channel suppresses desktop popups",
"file": "chat/notifications.spec.ts",
"status": "passed",
"duration": 20875,
"timeoutHint": false
},
{
"title": "syncs avatar changes for online and late-joining users and persists after restart",
"file": "chat/profile-avatar-sync.spec.ts",
"status": "passed",
"duration": 9605,
"timeoutHint": false
},
{
"title": "syncs display name and description changes for online and late-joining users and persists after restart",
"file": "chat/profile-avatar-sync.spec.ts",
"status": "passed",
"duration": 11864,
"timeoutHint": false
},
{
"title": "loads the chat-server image for online, late-joining, restarted, and discovery users",
"file": "chat/server-icon-sync.spec.ts",
"status": "passed",
"duration": 14204,
"timeoutHint": false
},
{
"title": "ships a launcher icon and splash for every required density",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 1,
"timeoutHint": false
},
{
"title": "replaces every stock Capacitor placeholder with the brand asset",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 2,
"timeoutHint": false
},
{
"title": "uses the brand purple as the adaptive-icon background",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 1,
"timeoutHint": false
},
{
"title": "renders the brand mark (white cat on a purple disc) in the launcher bitmap",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 13,
"timeoutHint": false
},
{
"title": "renders the splash art as the brand mark centred on a purple field",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 7,
"timeoutHint": false
},
{
"title": "insets the adaptive foreground so launcher masks do not clip the cat face",
"file": "mobile/android-app-icon.spec.ts",
"status": "passed",
"duration": 3,
"timeoutHint": false
},
{
"title": "greets a signed-out mobile visitor on /dashboard with the login screen",
"file": "mobile/mobile-login-on-startup.spec.ts",
"status": "passed",
"duration": 1217,
"timeoutHint": false
},
{
"title": "greets a signed-out mobile visitor on the app root with the login screen",
"file": "mobile/mobile-login-on-startup.spec.ts",
"status": "passed",
"duration": 1181,
"timeoutHint": false
},
{
"title": "exposes logout in the settings menu on mobile viewports",
"file": "mobile/mobile-settings-logout.spec.ts",
"status": "passed",
"duration": 1704,
"timeoutHint": false
},
{
"title": "runs chat, embed, soundboard, and profile APIs between two users",
"file": "plugins/plugin-api-two-users.spec.ts",
"status": "passed",
"duration": 27382,
"timeoutHint": false
},
{
"title": "installs, grants, activates, and logs an all-API test plugin",
"file": "plugins/plugin-manager-ui.spec.ts",
"status": "passed",
"duration": 4564,
"timeoutHint": false
},
{
"title": "covers plugin requirement, event, data, and websocket APIs with the fixture plugin",
"file": "plugins/plugin-support-api.spec.ts",
"status": "passed",
"duration": 1913,
"timeoutHint": false
},
{
"title": "single user screen share: video and audio flow to receiver, voice audio continues",
"file": "screen-share/screen-share.spec.ts",
"status": "passed",
"duration": 15375,
"timeoutHint": false
},
{
"title": "multiple users screen share simultaneously",
"file": "screen-share/screen-share.spec.ts",
"status": "passed",
"duration": 11093,
"timeoutHint": false
},
{
"title": "screen share connection stays stable for 10+ seconds",
"file": "screen-share/screen-share.spec.ts",
"status": "passed",
"duration": 23371,
"timeoutHint": false
},
{
"title": "a fresh account sees public servers in Popular Servers without searching",
"file": "servers/server-discovery-default.spec.ts",
"status": "passed",
"duration": 3149,
"timeoutHint": false
},
{
"title": "discovery falls back to the public listing when featured/trending routes 404",
"file": "servers/server-discovery-default.spec.ts",
"status": "passed",
"duration": 3110,
"timeoutHint": false
},
{
"title": "shows warning icon when a peer loses all connections",
"file": "settings/connectivity-warning.spec.ts",
"status": "passed",
"duration": 21313,
"timeoutHint": false
},
{
"title": "allows adding, removing, and reordering ICE servers",
"file": "settings/ice-server-settings.spec.ts",
"status": "passed",
"duration": 3855,
"timeoutHint": false
},
{
"title": "validates TURN entries require credentials",
"file": "settings/ice-server-settings.spec.ts",
"status": "passed",
"duration": 2311,
"timeoutHint": false
},
{
"title": "users with different ICE configs can voice chat together",
"file": "settings/stun-turn-fallback.spec.ts",
"status": "passed",
"duration": 11549,
"timeoutHint": false
},
{
"title": "keeps two users hearing each other after a data-channel error and close",
"file": "voice/data-channel-recovery.spec.ts",
"status": "passed",
"duration": 10035,
"timeoutHint": false
},
{
"title": "heals a three-user voice mesh when one client loses every data channel",
"file": "voice/data-channel-recovery.spec.ts",
"status": "passed",
"duration": 12070,
"timeoutHint": false
},
{
"title": "two users can ring, answer, chat, see self voice indicators, and exchange audio",
"file": "voice/direct-call.spec.ts",
"status": "passed",
"duration": 17480,
"timeoutHint": false
},
{
"title": "keeps private-call audio flowing after the data channel closes",
"file": "voice/direct-call.spec.ts",
"status": "passed",
"duration": 8950,
"timeoutHint": false
},
{
"title": "missing and ended private calls do not leave stale call controls behind",
"file": "voice/direct-call.spec.ts",
"status": "passed",
"duration": 5345,
"timeoutHint": false
},
{
"title": "callee is notified when the caller starts the call from the DM chat header",
"file": "voice/dm-header-call-ring.spec.ts",
"status": "passed",
"duration": 4235,
"timeoutHint": false
},
{
"title": "callee homed on another signal server is notified when called via their provisioned actor id",
"file": "voice/dm-header-call-ring.spec.ts",
"status": "passed",
"duration": 4815,
"timeoutHint": false
},
{
"title": "8 users with different signal configs can voice, mute, deafen, and chat concurrently",
"file": "voice/mixed-signal-config-voice.spec.ts",
"status": "passed",
"duration": 128215,
"timeoutHint": false
},
{
"title": "keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds",
"file": "voice/multi-signal-eight-user-voice.spec.ts",
"status": "passed",
"duration": 125136,
"timeoutHint": false
},
{
"title": "two users register, create server, join voice, and stay connected 10+ seconds with audio",
"file": "voice/voice-full-journey.spec.ts",
"status": "passed",
"duration": 21107,
"timeoutHint": false
},
{
"title": "clears stale mute state after abrupt disconnect and voice rejoin",
"file": "voice/voice-mute-state-reset.spec.ts",
"status": "passed",
"duration": 6888,
"timeoutHint": false
}
]
}
@@ -0,0 +1 @@
2026-08-12T11:11:34Z
@@ -0,0 +1 @@
2026-08-12T10:58:08Z
+17
View File
@@ -0,0 +1,17 @@
# Slow E2E tests (≥30s)
All of these **passed**. Listed because the product ask is that timeouts should not happen and the app should stay fast.
| Duration | File | Test |
| --- | --- | --- |
| 128.2s | `voice/mixed-signal-config-voice.spec.ts` | 8 users with different signal configs can voice, mute, deafen, and chat concurrently |
| 125.1s | `voice/multi-signal-eight-user-voice.spec.ts` | keeps 8 users on 2 signal apis while voice, mute, and deafen stay consistent for 20+ seconds |
| 53.4s | `auth/user-session-data-isolation.spec.ts` | gives a new user a blank slate and restores only that user local data after account switches |
| 33.7s | `chat/chat-message-features.spec.ts` | shows per-server channel lists on first saved-server click |
| 30.3s | `chat/multi-client-chat-sync.spec.ts` | syncs messages between same-user devices and late-joining users after offline gaps |
## Notes
- `mixed-signal-config-voice` and `multi-signal-eight-user-voice` are intentionally heavy (8 users / multi-signal). ~2 minutes each is suite design cost more than a single-user app hang.
- `user-session-data-isolation` (~53s) and channel/chat sync (~3034s) are closer to “app feels slow” candidates if they regress upward.
- Playwright config `timeout` is 90s per test; none of the failures/timeouts hit that ceiling in this run.
+1
View File
@@ -39,6 +39,7 @@
"build:prod:win": "npm run build:prod:all && electron-builder --win", "build:prod:win": "npm run build:prod:all && electron-builder --win",
"dev": "npm run build:electron && npm run electron:full", "dev": "npm run build:electron && npm run electron:full",
"dev:app": "npm run electron:dev", "dev:app": "npm run electron:dev",
"dev:peer": "./dev-peer.sh",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "npm run format && npm run sort:props && eslint . --fix", "lint:fix": "npm run format && npm run sort:props && eslint . --fix",
"format": "prettier --write \"toju-app/src/app/**/*.html\"", "format": "prettier --write \"toju-app/src/app/**/*.html\"",
+392
View File
@@ -0,0 +1,392 @@
/**
* Live voice diagnostic probe - paste into the DevTools console of a running
* dev window (Ctrl+Shift+I). Requires a development build, because it reads the
* Angular debug API (`window.ng`).
*
* Sampling starts on paste. Every 5s it records signaling state, per-peer
* connection/ICE/data-channel state, inbound + outbound audio RTP counters, the
* selected candidate pair, and the peer connection's creation timestamp.
*
* History lives in localStorage, not in the JS heap, so it survives a renderer
* reload and an app restart - both of which happen around a machine suspend.
* After any reload, paste the probe again: it resumes the same history, and the
* reload itself shows up as an event in the report.
*
* Usage:
* (paste) // loads and starts sampling
* __voiceProbe.summary() // compact report, safe to copy into chat
* __voiceProbe.stop()
* __voiceProbe.reset() // discard stored history before a new run
*/
(() => {
const INTERVAL_MS = 5_000;
/** Console heartbeat cadence, in samples, between which only changes are printed. */
const HEARTBEAT_EVERY = 12;
const STORAGE_KEY = 'metoyou_voice_probe_v1';
const MAX_SAMPLES = 1_500;
const HOST_SELECTORS = [
'app-rooms-side-panel',
'app-chat-room',
'app-dm-workspace'
];
/** Distinct per renderer load, so a reload is visible in the stored history. */
const LOAD_ID = Math.round(performance.timeOrigin);
function loadStoredSamples() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveStoredSamples(samples) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(samples.slice(-MAX_SAMPLES)));
} catch (error) {
console.warn('[voice-probe] could not persist samples', error);
}
}
function findRealtime() {
const debugApi = window.ng;
if (!debugApi || typeof debugApi.getComponent !== 'function')
return null;
for (const selector of HOST_SELECTORS) {
for (const host of Array.from(document.querySelectorAll(selector))) {
let component = null;
try {
component = debugApi.getComponent(host);
} catch {
continue;
}
const realtime = component && component.realtime;
if (realtime && realtime.peerManager && realtime.peerManager.activePeerConnections)
return realtime;
}
}
return null;
}
async function readPeer(peerId, peerData) {
const connection = peerData.connection;
const sample = {
createdAt: peerData.createdAt || 0,
dataChannel: peerData.dataChannel ? peerData.dataChannel.readyState : 'none',
iceState: connection ? connection.iceConnectionState : 'none',
inPackets: 0,
isInitiator: peerData.isInitiator === true,
outPackets: 0,
pair: '?',
peerId: String(peerId).slice(0, 8),
state: connection ? connection.connectionState : 'none'
};
if (!connection)
return sample;
let stats = null;
try {
stats = await connection.getStats();
} catch {
return sample;
}
const candidates = new Map();
let selectedPair = null;
stats.forEach((report) => {
if (report.type === 'local-candidate' || report.type === 'remote-candidate')
candidates.set(report.id, report.candidateType);
if (report.type === 'candidate-pair' && report.state === 'succeeded' && (report.selected || report.nominated))
selectedPair = report;
const kind = report.kind || report.mediaType;
if (report.type === 'inbound-rtp' && kind === 'audio')
sample.inPackets += report.packetsReceived || 0;
if (report.type === 'outbound-rtp' && kind === 'audio')
sample.outPackets += report.packetsSent || 0;
});
if (selectedPair) {
const localType = candidates.get(selectedPair.localCandidateId) || '?';
const remoteType = candidates.get(selectedPair.remoteCandidateId) || '?';
sample.pair = `${localType}/${remoteType}`;
}
return sample;
}
/**
* The local capture state. Without this, "zero RTP" is ambiguous: it looks identical
* whether the call died or the user simply is not in one any more.
*/
function readLocalMedia(realtime) {
let voice = 'unknown';
let mic = 'unknown';
try {
voice = realtime.isVoiceConnected() ? 'in-voice' : 'not-in-voice';
} catch {}
try {
const stream = realtime.getLocalStream();
mic = stream
? stream.getAudioTracks()
.map((track) => `${track.readyState}${track.enabled ? '' : '/disabled'}${track.muted ? '/muted' : ''}`)
.join('+') || 'no-audio-tracks'
: 'no-stream';
} catch {}
return {
mic,
voice
};
}
async function takeSample(realtime) {
const peers = [];
const entries = Array.from(realtime.peerManager.activePeerConnections.entries());
for (const [peerId, peerData] of entries) {
peers.push(await readPeer(peerId, peerData));
}
let signaling = 'unknown';
try {
signaling = realtime.isConnected() ? 'up' : 'DOWN';
} catch {}
const local = readLocalMedia(realtime);
return {
at: Date.now(),
load: LOAD_ID,
mic: local.mic,
peers,
signaling,
voice: local.voice
};
}
let timerId = null;
let lastFingerprint = '';
function start() {
if (timerId !== null) {
console.log('[voice-probe] already running');
return;
}
const realtime = findRealtime();
if (!realtime) {
console.error('[voice-probe] no realtime service found. Open a room/voice view on a dev build first.');
return;
}
const tick = () => {
takeSample(realtime)
.then((sample) => {
const samples = loadStoredSamples();
samples.push(sample);
saveStoredSamples(samples);
const peerText = sample.peers
.map((peer) => `${peer.peerId} ${peer.state} in=${peer.inPackets} out=${peer.outPackets}`)
.join(' | ');
const fingerprint = `${sample.signaling}|${sample.voice}|${sample.mic}|`
+ sample.peers.map((peer) => `${peer.peerId}:${peer.state}:${peer.dataChannel}:${peer.createdAt}`).join(',');
// Every console line is retained by DevTools for the life of the session, and a
// long soak with an open inspector is how the debugger itself ends up using
// gigabytes. Print on change, plus a periodic heartbeat.
const isHeartbeat = samples.length % HEARTBEAT_EVERY === 1;
if (isHeartbeat || fingerprint !== lastFingerprint) {
console.log(
`[voice-probe] #${samples.length} signaling=${sample.signaling} ${sample.voice} mic=${sample.mic}`
+ ` ${peerText || '(no peers)'}`
);
}
lastFingerprint = fingerprint;
})
.catch((error) => console.error('[voice-probe] sample failed', error));
};
tick();
timerId = setInterval(tick, INTERVAL_MS);
}
function stop() {
if (timerId !== null) {
clearInterval(timerId);
timerId = null;
}
console.log(`[voice-probe] stopped with ${loadStoredSamples().length} samples stored`);
}
function reset() {
stop();
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
console.log('[voice-probe] history cleared');
}
function describeGap(previous, current) {
const gapMs = current.at - previous.at;
if (gapMs < INTERVAL_MS * 2.5)
return null;
return `GAP ${Math.round(gapMs / 1000)}s with no samples`;
}
function peerLines(previous, current) {
const lines = [];
for (const peer of current.peers) {
const before = previous.peers.find((candidate) => candidate.peerId === peer.peerId);
if (!before) {
lines.push(` + peer ${peer.peerId} appeared (state=${peer.state}, initiator=${peer.isInitiator})`);
continue;
}
if (before.createdAt !== peer.createdAt) {
lines.push(
` ! peer ${peer.peerId} REBUILT - connection now created at ${new Date(peer.createdAt).toISOString()}`
+ ` (was ${new Date(before.createdAt).toISOString()})`
);
}
if (before.state !== peer.state)
lines.push(` ~ peer ${peer.peerId} state ${before.state} -> ${peer.state}`);
if (before.dataChannel !== peer.dataChannel)
lines.push(` ~ peer ${peer.peerId} data channel ${before.dataChannel} -> ${peer.dataChannel}`);
if (before.pair !== peer.pair)
lines.push(` ~ peer ${peer.peerId} candidate pair ${before.pair} -> ${peer.pair}`);
// Only a peer that is supposed to be carrying voice can "stall"; outside a call
// zero RTP is the correct reading, not a fault.
if (peer.state === 'connected' && before.state === 'connected' && current.voice === 'in-voice') {
const inDelta = peer.inPackets - before.inPackets;
const outDelta = peer.outPackets - before.outPackets;
if (inDelta <= 0 || outDelta <= 0)
lines.push(` x peer ${peer.peerId} audio stalled (in +${inDelta}, out +${outDelta})`);
}
}
for (const peer of previous.peers) {
if (!current.peers.some((candidate) => candidate.peerId === peer.peerId))
lines.push(` - peer ${peer.peerId} disappeared`);
}
return lines;
}
function describePeers(sample) {
if (sample.peers.length === 0)
return 'none';
return sample.peers
.map((peer) => `${peer.peerId}(${peer.state}, born ${new Date(peer.createdAt).toISOString()})`)
.join(', ');
}
function summary() {
const samples = loadStoredSamples();
if (samples.length === 0)
return '[voice-probe] no samples stored';
const first = samples[0];
const last = samples[samples.length - 1];
const loads = Array.from(new Set(samples.map((sample) => sample.load)));
const lines = [
'=== voice-probe summary ===',
`samples: ${samples.length} over ${Math.round((last.at - first.at) / 1000)}s, renderer loads: ${loads.length}`,
`voice at start: ${first.voice}, mic ${first.mic}`,
`voice at end: ${last.voice}, mic ${last.mic}`,
`peers at start: ${describePeers(first)}`,
`peers at end: ${describePeers(last)}`,
`audio totals end: ${last.peers.map((peer) => `${peer.peerId} in=${peer.inPackets} out=${peer.outPackets}`).join(', ')}`,
'--- events ---'
];
const eventCountBefore = lines.length;
for (let index = 1; index < samples.length; index++) {
const previous = samples[index - 1];
const current = samples[index];
const gap = describeGap(previous, current);
const reloaded = previous.load !== current.load;
// Counters and peer identity are meaningless across a reload: the whole app restarted.
const events = reloaded ? [] : peerLines(previous, current);
if (reloaded)
events.push(' ! RENDERER RELOADED - the app restarted, so peers below are new by definition');
if (previous.mic !== current.mic)
events.unshift(` ~ local mic ${previous.mic} -> ${current.mic}`);
if (previous.voice !== current.voice)
events.unshift(` ~ voice ${previous.voice} -> ${current.voice}`);
if (previous.signaling !== current.signaling)
events.unshift(` ~ signaling ${previous.signaling} -> ${current.signaling}`);
if (!gap && events.length === 0)
continue;
lines.push(`#${index + 1} +${Math.round((current.at - first.at) / 1000)}s${gap ? ` ${gap}` : ''}`);
lines.push(...events);
}
if (lines.length === eventCountBefore)
lines.push(' (no state changes, no rebuilds, no audio stalls, no reloads)');
const text = lines.join('\n');
console.log(text);
return text;
}
window.__voiceProbe = {
reset,
start,
stop,
summary
};
const stored = loadStoredSamples();
console.log(
`[voice-probe] loaded (load id ${LOAD_ID}). ${stored.length} samples already stored.`
+ ` Sampling every ${INTERVAL_MS / 1000}s - call __voiceProbe.summary() when done.`
);
start();
})();