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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Documentation/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@

> **Convention:** `~~strikethrough~~` = done, removed by design, or YAGNI-deferred. Plain `[ ]` = genuinely open. `[ ] (blocked: …)` = open but waiting on something external.

## Recent Engineering Log

### 2026-08-17 — Announcer stuck-recovery escape hatch

- [x] Bound queued DAVE/media recovery: if the announcer remains paused in recovery for 60 seconds, its existing health watchdog now initiates the normal clean voice rejoin instead of leaving it silently connected forever.
- [x] Added voice pipeline state, latest 50 voice events, UDP keepalive history, live transport ownership, voice-resume state, and DAVE/MLS transition diagnostics to exported diagnostics, so voice failures are no longer hidden by noisy main-gateway reconnect logs.
- [x] Ported SwiftMiner's window-attached export progress sheet, so diagnostics visibly prepare before the save sheet appears.

---

## Overview
Expand Down
215 changes: 210 additions & 5 deletions Sources/SwiftBot/LogExporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ enum SwiftBotLogRedactor {
enum LogExporter {

@MainActor
static func buildReport(from app: AppModel, generatedAt: Date = Date()) -> String {
static func buildReport(from app: AppModel, generatedAt: Date = Date()) async -> String {
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime]

Expand Down Expand Up @@ -130,6 +130,58 @@ enum LogExporter {
}
out += "\n"

// Voice diagnostics are kept separately from the general system log,
// which can otherwise be dominated by gateway reconnect entries and
// hide the transition that made an announcer go silent.
let voiceHealth = app.announcerHealth
out += "=== Voice Announcer ===\n"
out += "connectionStatus=\(app.voiceConnectionStatus.displayLabel)\n"
out += "phase=\(voiceHealth.phase.rawValue)\n"
out += "queueDepth=\(voiceHealth.queueDepth)\n"
out += "retryStreak=\(voiceHealth.retryStreak)\n"
out += "lastQueuedAt=\(voiceHealth.lastQueuedAt.map { iso.string(from: $0) } ?? "-")\n"
out += "lastSpokenAt=\(voiceHealth.lastSpokenAt.map { iso.string(from: $0) } ?? "-")\n"
out += "lastFailureAt=\(voiceHealth.lastFailureAt.map { iso.string(from: $0) } ?? "-")\n"
out += "lastFailureReason=\(SwiftBotLogRedactor.redact(voiceHealth.lastFailureReason ?? "-"))\n"
out += "\n"

let transportDiagnostics: VoicePlaybackService.DiagnosticsSnapshot? = if let playback = app.voicePlaybackServiceStorage {
await playback.diagnosticsSnapshot()
} else {
nil
}
out += "=== Voice Transport ===\n"
if let transportDiagnostics {
let stagedTransitions = transportDiagnostics.davePendingTransitionIds
.map(String.init)
.joined(separator: ",")
out += "status=\(transportDiagnostics.status)\n"
out += "connectionGeneration=\(transportDiagnostics.connectionGeneration)\n"
out += "lastFailureGeneration=\(transportDiagnostics.lastFailureGeneration.map(String.init) ?? "-")\n"
out += "lastFailureReason=\(SwiftBotLogRedactor.redact(transportDiagnostics.lastFailureReason ?? "-"))\n"
out += "gateway=\(transportDiagnostics.hasGateway) transport=\(transportDiagnostics.hasTransport) encryption=\(transportDiagnostics.hasEncryption) opus=\(transportDiagnostics.hasOpusEncoder) ssrc=\(transportDiagnostics.hasSSRC)\n"
out += "isSpeaking=\(transportDiagnostics.isSpeaking) speakingElapsedSeconds=\(transportDiagnostics.speakingElapsedSeconds.map { String(format: "%.2f", $0) } ?? "-") firstAudioFrameSent=\(transportDiagnostics.didSendFirstAudioFrame)\n"
out += "lastAudioFrameSentAt=\(transportDiagnostics.lastAudioFrameSentAt.map { iso.string(from: $0) } ?? "-")\n"
out += "keepaliveCounter=\(transportDiagnostics.keepaliveCounter) failures=\(transportDiagnostics.keepaliveFailures)\n"
out += "lastKeepaliveAttemptAt=\(transportDiagnostics.lastKeepaliveAttemptAt.map { iso.string(from: $0) } ?? "-")\n"
out += "lastKeepaliveSuccessAt=\(transportDiagnostics.lastKeepaliveSuccessAt.map { iso.string(from: $0) } ?? "-")\n"
out += "lastKeepaliveFailureAt=\(transportDiagnostics.lastKeepaliveFailureAt.map { iso.string(from: $0) } ?? "-")\n"
out += "lastKeepaliveFailureReason=\(SwiftBotLogRedactor.redact(transportDiagnostics.lastKeepaliveFailureReason ?? "-"))\n"
out += "awaitingVoiceResume=\(transportDiagnostics.awaitingVoiceResume) resumeAttemptsRemaining=\(transportDiagnostics.voiceResumeAttemptsRemaining)\n"
out += "pathRecoveryRequested=\(transportDiagnostics.pathRecoveryRequested) pathRecoveryBudgetRemaining=\(transportDiagnostics.networkPathRecoveryBudgetRemaining)\n"
out += "daveRequired=\(transportDiagnostics.daveMediaRequired) daveGatePending=\(transportDiagnostics.daveTransitionGatePending) daveMediaContextGeneration=\(transportDiagnostics.daveMediaContextGeneration)\n"
out += "daveDowngradeTransitionId=\(transportDiagnostics.pendingDaveDowngradeTransitionId.map(String.init) ?? "-") daveSoleMemberReset=\(transportDiagnostics.pendingDaveSoleMemberReset)\n"
out += "daveSessionGeneration=\(transportDiagnostics.daveSessionGeneration.map(String.init) ?? "-") protocolVersion=\(transportDiagnostics.daveProtocolVersion.map(String.init) ?? "-") handshake=\(transportDiagnostics.daveHandshakeState ?? "-") mediaReady=\(transportDiagnostics.daveMediaReady.map(String.init) ?? "-")\n"
out += "daveAppliedTransitions=\(transportDiagnostics.daveAppliedTransitionCount.map(String.init) ?? "-") pendingEpoch=\(transportDiagnostics.davePendingEpoch.map(String.init) ?? "-") pendingTransition=\(transportDiagnostics.davePendingTransitionId.map(String.init) ?? "-") activeTransition=\(transportDiagnostics.daveActiveTransitionId.map(String.init) ?? "-")\n"
out += "daveStagedTransitions=\(stagedTransitions.isEmpty ? "-" : stagedTransitions) pendingOutboundActions=\(transportDiagnostics.davePendingOutboundActionCount.map(String.init) ?? "-") lastRecoveryAction=\(transportDiagnostics.daveLastRecoveryAction ?? "-")\n"
out += "daveLastTransitionAt=\(transportDiagnostics.daveLastTransitionAt.map { iso.string(from: $0) } ?? "-")\n"
out += "daveEncryptSuccesses=\(transportDiagnostics.daveEncryptionSuccessCount.map(String.init) ?? "-") failures=\(transportDiagnostics.daveEncryptionFailureCount.map(String.init) ?? "-")\n"
out += "daveLastMlsError=\(SwiftBotLogRedactor.redact(transportDiagnostics.daveLastMlsError ?? "-"))\n"
} else {
out += "state=not initialized\n"
}
out += "\n"

// Cluster / SwiftMesh
out += "=== SwiftMesh ===\n"
let cs = app.clusterSnapshot
Expand Down Expand Up @@ -211,6 +263,16 @@ enum LogExporter {
out += "\(ts) \(status) \(SwiftBotLogRedactor.redact(c.user)) · \(SwiftBotLogRedactor.redact(c.server)) · \(SwiftBotLogRedactor.redact(c.channel)) · route=\(c.executionRoute) on=\(c.executionNode) · \(SwiftBotLogRedactor.redact(c.command))\n"
}
}

out += "\n-- Voice Log (most recent 50) --\n"
let voiceEntries = app.voiceLog.prefix(50)
if voiceEntries.isEmpty {
out += "(none)\n"
} else {
for entry in voiceEntries {
out += "[\(iso.string(from: entry.time))] \(SwiftBotLogRedactor.redact(entry.description))\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Redact channel snowflakes from the exported voice log

When the voice log contains entries such as Voice join requested for channel 123456789012345678 (generated by connectVoice), this new export loop writes the snowflake unchanged. SwiftBotLogRedactor only recognizes forms such as channel_id=..., not the natural-language channel ... form used by these entries, so a report attached to a public GitHub issue can disclose Discord IDs despite the release notes describing them as scrubbed. Extend the redaction rule or omit raw IDs from exported voice entries.

Useful? React with 👍 / 👎.

}
}
out += "\n-- System Log (most recent 500 lines) --\n"
let lines = app.logs.lines.suffix(500)
if lines.isEmpty {
Expand All @@ -224,20 +286,28 @@ enum LogExporter {
return out
}

/// Presents SwiftMiner's export flow: first a small, window-attached progress
/// sheet while potentially slow diagnostics are gathered, then a save sheet.
@MainActor
static func presentSavePanel(app: AppModel) {
static func presentSavePanel(app: AppModel) async {
let progressSheet = presentProgressSheet()
defer { dismiss(progressSheet) }

// Give AppKit a chance to display feedback before awaiting actor-backed
// voice/DAVE diagnostics and redacting a potentially large activity log.
await Task.yield()
let generatedAt = Date()
let report = buildReport(from: app, generatedAt: generatedAt)
let report = await buildReport(from: app, generatedAt: generatedAt)

let panel = NSSavePanel()
panel.allowedContentTypes = [.plainText]
panel.nameFieldStringValue = defaultFilename(for: generatedAt)
panel.title = "Export Diagnostic Logs"
panel.message = "Save a redacted SwiftBot diagnostic report. Discord tokens, mesh secrets, API keys, snowflakes, and emails are scrubbed."
panel.message = "Save a redacted SwiftBot diagnostic report you can attach to a GitHub issue."
panel.canCreateDirectories = true
panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first

guard panel.runModal() == .OK, let url = panel.url else { return }
guard await present(panel) == .OK, let url = panel.url else { return }

do {
try report.write(to: url, atomically: true, encoding: .utf8)
Expand All @@ -251,6 +321,86 @@ enum LogExporter {
}
}

/// A save panel started from a SwiftUI command needs a window-attached sheet
/// on current macOS releases. `runModal()` can return without presenting in
/// that context, which made exporting look like a no-op.
@MainActor
private static func present(_ panel: NSSavePanel) async -> NSApplication.ModalResponse {
guard let window = NSApp.keyWindow ?? NSApp.mainWindow else {
return panel.runModal()
}

return await withCheckedContinuation { continuation in
panel.beginSheetModal(for: window) { response in
continuation.resume(returning: response)
}
}
}

private struct ProgressSheet {
let panel: NSPanel
let parentWindow: NSWindow?
let symbolCycler: DiagnosticsSymbolCycler
}

/// Displays immediately while the report is snapshotted, redacted, and
/// formatted, avoiding an export action that appears to have been ignored.
@MainActor
private static func presentProgressSheet() -> ProgressSheet {
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 380, height: 154),
styleMask: [.titled],
backing: .buffered,
defer: false
)
panel.title = "Export Diagnostic Logs"
panel.isReleasedWhenClosed = false
panel.isMovable = false
panel.standardWindowButton(.closeButton)?.isHidden = true
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
panel.standardWindowButton(.zoomButton)?.isHidden = true

let content = NSView(frame: panel.contentView?.bounds ?? .zero)

let symbolCycler = DiagnosticsSymbolCycler(frame: NSRect(x: 27, y: 71, width: 30, height: 30))
content.addSubview(symbolCycler)

let title = NSTextField(labelWithString: "Preparing diagnostics…")
title.font = .systemFont(ofSize: 16, weight: .semibold)
title.frame = NSRect(x: 74, y: 90, width: 272, height: 22)
content.addSubview(title)

let detail = NSTextField(wrappingLabelWithString: "Collecting announcer, Discord voice, and activity data. This can take a moment for a large log.")
detail.font = .systemFont(ofSize: 13)
detail.textColor = .secondaryLabelColor
detail.maximumNumberOfLines = 2
detail.frame = NSRect(x: 74, y: 42, width: 272, height: 40)
content.addSubview(detail)

panel.contentView = content
symbolCycler.start()

if let parentWindow = NSApp.keyWindow ?? NSApp.mainWindow {
parentWindow.beginSheet(panel)
return ProgressSheet(panel: panel, parentWindow: parentWindow, symbolCycler: symbolCycler)
}

panel.center()
panel.level = .floating
panel.makeKeyAndOrderFront(nil)
return ProgressSheet(panel: panel, parentWindow: nil, symbolCycler: symbolCycler)
}

@MainActor
private static func dismiss(_ progressSheet: ProgressSheet) {
progressSheet.symbolCycler.stop()
if let parentWindow = progressSheet.parentWindow {
parentWindow.endSheet(progressSheet.panel)
} else {
progressSheet.panel.orderOut(nil)
}
}

static func defaultFilename(for date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
Expand All @@ -259,3 +409,58 @@ enum LogExporter {
return "SwiftBot-logs-\(formatter.string(from: date)).txt"
}
}

/// Cycles through relevant symbols while diagnostics are prepared, providing
/// the same visible activity cue as SwiftMiner's diagnostics export sheet.
private final class DiagnosticsSymbolCycler: NSImageView {
private let symbols: [(name: String, color: NSColor)] = [
("speaker.wave.2.fill", .systemBlue),
("waveform", .systemPurple),
("antenna.radiowaves.left.and.right", .systemIndigo),
("text.bubble.fill", .systemTeal)
]
private var symbolIndex = 0
private var timer: Timer?

override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
imageScaling = .scaleProportionallyUpOrDown
setAccessibilityLabel("Preparing diagnostics")
updateSymbol()
}

required init?(coder: NSCoder) {
nil
}

func start() {
guard timer == nil else { return }

let timer = Timer(
timeInterval: 0.65,
target: self,
selector: #selector(advanceSymbol),
userInfo: nil,
repeats: true
)
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}

func stop() {
timer?.invalidate()
timer = nil
}

@objc private func advanceSymbol() {
symbolIndex = (symbolIndex + 1) % symbols.count
updateSymbol()
}

private func updateSymbol() {
let symbol = symbols[symbolIndex]
contentTintColor = symbol.color
image = NSImage(systemSymbolName: symbol.name, accessibilityDescription: "Preparing diagnostics")
}

}
15 changes: 13 additions & 2 deletions Sources/SwiftBot/Models/VoiceAnnouncerHealth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,28 @@ struct VoiceAnnouncerHealth: Sendable, Equatable {
var isDraining: Bool = false

func isStalled(now: Date = Date(), threshold: TimeInterval = 60) -> Bool {
guard !isPaused else { return false }
switch phase {
case .rendering, .sending:
guard !isPaused else { return false }
guard let activeStartedAt else { return false }
return now.timeIntervalSince(activeStartedAt) >= threshold
case .queued:
guard !isPaused else { return false }
guard queueDepth > 0, let lastQueuedAt else { return false }
return now.timeIntervalSince(lastQueuedAt) >= threshold
case .failed:
return true
case .idle, .paused, .recovering:
case .recovering:
// A short DAVE media re-key is expected, but a recovery with
// queued reads must be bounded. Previously this state was
// considered healthy forever. Recovery deliberately pauses the
// queue, so `isPaused` must not suppress this check; otherwise a
// lost media-ready callback leaves the bot connected but silent.
guard queueDepth > 0 else { return false }
let recoveryStartedAt = lastFailureAt ?? lastRecoveryAt ?? lastQueuedAt
guard let recoveryStartedAt else { return false }
return now.timeIntervalSince(recoveryStartedAt) >= threshold
case .idle, .paused:
return false
}
}
Expand Down
Loading
Loading