Decouple transcripts from note text; end-to-end watch transfer acks

Schema v3: transcription attempts accumulate on AudioInfo (text, locale,
confidence, forced flag) — latest attempt is displayed, payload.text is
purely user writing, v2 notes migrate at read time. Editor shows the
transcript in its own live-updating section with insert-into-note; search
matches transcripts weighted by confidence. Watch recordings are now
deleted only on the phone's durable-ingest ack (transferUserInfo), closing
every phone-side loss window; failed sidecar writes unstage instead of
orphaning, and unreadable inbox recordings are surfaced in logs.

Claude-Session: https://claude.ai/code/session_014esDWi42URLEC6Cj17hGQ3
This commit is contained in:
2026-07-16 12:29:49 -04:00
parent c29d25748a
commit c5c90e9441
15 changed files with 546 additions and 55 deletions
@@ -8,11 +8,14 @@ import WatchConnectivity
///
/// Each queued recording is a `<ULID>.m4a` plus a `<ULID>.meta.plist` sidecar in
/// `Documents/Recordings/`. `transferFile` hands the audio to WatchConnectivity
/// with an `AudioNotePayload` as metadata. Local files are deleted **only** in
/// `didFinish` with a nil error so a delivery we never confirm (app killed
/// mid-flight, transfer failed) is re-enqueued by `reconcileQueue()` from the
/// persisted sidecar on the next activation. `pendingCount` (files still on disk)
/// drives the "N waiting for iPhone" footer.
/// with an `AudioNotePayload` as metadata. Local files are deleted **only** when
/// the phone sends an `IngestAck` the end-to-end confirmation that the
/// recording is durably in the iCloud container (or permanently vetoed there).
/// WC-level `didFinish` success is not enough: the phone can still fail after
/// delivery (staging, iCloud write), and this local copy is the only recovery.
/// Anything never acked is re-enqueued by `reconcileQueue()` from the persisted
/// sidecar on the next activation/foreground; the phone dedupes and re-acks.
/// `pendingCount` (files still on disk) drives the "N waiting for iPhone" footer.
///
/// Strict concurrency: the delegate callbacks are nonisolated and run on
/// WatchConnectivity's background queue they extract Sendable values (a file
@@ -30,6 +33,13 @@ final class WatchTransferService: NSObject {
private var session: WCSession?
/// Recordings WC reported delivered this session but the phone hasn't yet
/// acked as ingested. Skipped by `reconcileQueue` so a foreground pass right
/// after delivery doesn't re-send a whole audio file the phone is busy
/// ingesting. In-memory only: after a relaunch these fall back into the
/// reconcile pool, and a redundant redelivery is harmless (phone dedupes).
private var deliveredAwaitingAck: Set<String> = []
func activate() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
@@ -62,6 +72,7 @@ final class WatchTransferService: NSObject {
let entries = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? []
for m4a in entries where m4a.pathExtension == "m4a" {
if inFlight.contains(m4a.lastPathComponent) { continue }
if deliveredAwaitingAck.contains(m4a.lastPathComponent) { continue }
let base = m4a.deletingPathExtension().lastPathComponent
guard ULID(string: base) != nil else { continue }
let meta = readSidecar(base: base)
@@ -93,13 +104,20 @@ final class WatchTransferService: NSObject {
session.transferFile(url, metadata: payload.metadata())
}
/// Delete a delivered recording and its sidecar (called only on a confirmed
/// `didFinish` with no error).
/// Delete an **acked** recording and its sidecar called only when the
/// phone's `IngestAck` arrives, never on mere WC delivery success.
private func completeTransfer(fileName: String) {
let dir = WatchAudioRecorder.recordingsDirectory
let base = (fileName as NSString).deletingPathExtension
try? FileManager.default.removeItem(at: dir.appending(path: fileName))
try? FileManager.default.removeItem(at: sidecarURL(base: base))
deliveredAwaitingAck.remove(fileName)
refreshPendingCount()
}
/// WC delivered the file to the phone; park it until the ingest ack.
private func markDelivered(fileName: String) {
deliveredAwaitingAck.insert(fileName)
refreshPendingCount()
}
@@ -160,6 +178,14 @@ extension WatchTransferService: WCSessionDelegate {
Task { @MainActor in self.refreshPendingCount() }
return
}
Task { @MainActor in self.completeTransfer(fileName: fileName) }
// Delivered ingested: keep the local copy until the phone's ack.
Task { @MainActor in self.markDelivered(fileName: fileName) }
}
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
// The phone's ingest ack the recording is durable in iCloud (or
// permanently vetoed); the local copy has done its job.
guard let ack = IngestAck(userInfo: userInfo) else { return }
Task { @MainActor in self.completeTransfer(fileName: "\(ack.id).m4a") }
}
}