forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCaptureArchiveRepository.swift
More file actions
320 lines (284 loc) · 11.3 KB
/
Copy pathCaptureArchiveRepository.swift
File metadata and controls
320 lines (284 loc) · 11.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
import Foundation
/// Adapts an Omi-capture deep link to the canonical Conversations detail.
/// The capture repository resolves provenance; this policy keeps focus
/// acknowledgement tied to the exact record and playback preparation.
enum CaptureConversationFocusRoutingPolicy {
static func initialMoment(
for focus: ChatFirstPendingFocus?,
conversationID: String
) -> TimeInterval? {
guard case .capture(let id, let momentTimestamp) = focus, id == conversationID else { return nil }
return momentTimestamp
}
static func resolvedFocus(
for focus: ChatFirstPendingFocus?,
conversationID: String,
didResolve: Bool
) -> ChatFirstPendingFocus? {
guard didResolve,
case .capture(let id, let momentTimestamp) = focus,
id == conversationID
else { return nil }
return .capture(id: id, momentTs: momentTimestamp)
}
}
/// The capture archive has a single, non-negotiable provenance query. It is
/// intentionally separate from `ConversationListQuery`, whose legacy callers
/// may display mixed desktop, phone, and hardware conversations.
struct CaptureArchiveQuery: Equatable, Sendable {
static let pageSize = 50
let offset: Int
let limit: Int
/// These are deliberately constants rather than caller-controlled query
/// knobs. The archive must never be repurposed as a generic conversations
/// list by accidentally changing a page request.
let statuses: [ConversationStatus] = [.completed, .processing]
let source: ConversationSource = .omi
let includeDiscarded = false
init(offset: Int = 0, limit: Int = CaptureArchiveQuery.pageSize) {
self.offset = offset
self.limit = limit
}
}
private enum CaptureArchiveRepositoryError: Error {
/// A filtered server/cache response containing another provenance is a
/// contract failure, not an opportunity to client-filter a mixed page.
case receivedNonArchiveCapture
}
extension ServerConversation {
/// The archive's provenance contract. Beyond the repository itself, the only
/// legitimate reader is citation routing: a chat citation names whatever the
/// agent retrieved, and this predicate decides whether the capture focus may
/// carry it or it must open as the exact fetched record.
var isOmiCaptureArchiveRecord: Bool {
source == .omi && !discarded && (status == .completed || status == .processing)
}
}
protocol CaptureArchiveRemoteDataSource: Sendable {
func list(query: CaptureArchiveQuery) async throws -> [ServerConversation]
func count(query: CaptureArchiveQuery) async throws -> Int
func detail(id: String) async throws -> ServerConversation
}
protocol CaptureArchiveLocalDataSource: Sendable {
func list(query: CaptureArchiveQuery) async throws -> [ServerConversation]
func count(query: CaptureArchiveQuery) async throws -> Int
func detail(id: String) async throws -> ServerConversation?
func store(_ conversation: ServerConversation) async throws
}
struct LiveCaptureArchiveRemoteDataSource: CaptureArchiveRemoteDataSource {
func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] {
try await APIClient.shared.getConversations(
limit: query.limit,
offset: query.offset,
statuses: query.statuses,
sources: [query.source],
includeDiscarded: query.includeDiscarded
)
}
func count(query: CaptureArchiveQuery) async throws -> Int {
try await APIClient.shared.getConversationsCount(
includeDiscarded: query.includeDiscarded,
statuses: query.statuses,
sources: [query.source]
)
}
func detail(id: String) async throws -> ServerConversation {
try await APIClient.shared.getOmiCapture(id: id)
}
}
struct LiveCaptureArchiveLocalDataSource: CaptureArchiveLocalDataSource {
func list(query: CaptureArchiveQuery) async throws -> [ServerConversation] {
precondition(query.source == .omi && query.includeDiscarded == false)
return try await TranscriptionStorage.shared.getLocalOmiCaptureConversations(
limit: query.limit,
offset: query.offset
)
}
func count(query: CaptureArchiveQuery) async throws -> Int {
precondition(query.source == .omi && query.includeDiscarded == false)
return try await TranscriptionStorage.shared.getLocalOmiCaptureConversationsCount()
}
func detail(id: String) async throws -> ServerConversation? {
try await TranscriptionStorage.shared.getCachedConversation(id: id)
}
func store(_ conversation: ServerConversation) async throws {
_ = try await TranscriptionStorage.shared.syncServerConversation(conversation)
}
}
/// Read-only cache/network owner for the universal Omi capture archive. It
/// has no mutation or mixed-source fallback path by design.
@MainActor
final class CaptureArchiveRepository: ObservableObject {
@Published private(set) var captures: [ServerConversation] = []
@Published private(set) var selectedCapture: ServerConversation?
@Published private(set) var count: Int?
@Published private(set) var isLoading = false
@Published private(set) var isLoadingMore = false
@Published private(set) var errorMessage: String?
private let remote: any CaptureArchiveRemoteDataSource
private let local: any CaptureArchiveLocalDataSource
private var hasLoaded = false
private var activeDetailLoadToken = 0
private var activeListLoadToken = 0
private nonisolated(unsafe) var ownerChangeObserver: NSObjectProtocol?
init(
remote: any CaptureArchiveRemoteDataSource = LiveCaptureArchiveRemoteDataSource(),
local: any CaptureArchiveLocalDataSource = LiveCaptureArchiveLocalDataSource()
) {
self.remote = remote
self.local = local
ownerChangeObserver = NotificationCenter.default.addObserver(
forName: .runtimeOwnerDidChange, object: nil, queue: nil
) { [weak self] _ in
MainActor.assumeIsolated {
self?.resetForRuntimeOwnerChange()
}
}
}
deinit {
if let ownerChangeObserver {
NotificationCenter.default.removeObserver(ownerChangeObserver)
}
}
var hasMore: Bool {
guard let count else { return false }
return captures.count < count
}
func loadInitial(force: Bool = false) async {
guard force || !hasLoaded else { return }
hasLoaded = true
let token = beginListLoad()
isLoading = true
errorMessage = nil
let query = CaptureArchiveQuery()
do {
async let cachedRows = local.list(query: query)
async let cachedCount = local.count(query: query)
let (unvalidatedRows, localCount) = try await (cachedRows, cachedCount)
guard token == activeListLoadToken else { return }
captures = try validatedArchiveRows(unvalidatedRows)
count = localCount
} catch {
// A stale cache must not block the server-authoritative source-scoped
// request. The subsequent failure still surfaces honestly below.
}
guard token == activeListLoadToken else { return }
await reloadFirstPage(query: query, token: token)
guard token == activeListLoadToken else { return }
isLoading = false
}
func refresh() async {
await loadInitial(force: true)
}
func loadNextPage() async {
guard !isLoadingMore, !isLoading, errorMessage == nil, hasMore else { return }
isLoadingMore = true
defer { isLoadingMore = false }
let token = activeListLoadToken
let query = CaptureArchiveQuery(offset: captures.count)
do {
let page = try validatedArchiveRows(await remote.list(query: query))
guard token == activeListLoadToken else { return }
for capture in page where !captures.contains(where: { $0.id == capture.id }) {
captures.append(capture)
try? await local.store(capture)
}
} catch {
guard token == activeListLoadToken else { return }
// Do not retry without the source predicate. The user must choose Refresh.
errorMessage = "Omi-device captures are unavailable. Refresh to try again."
}
}
func select(_ capture: ServerConversation) {
selectedCapture = capture
}
/// Selection is the archive's only detail-presentation state. Clearing it
/// also fences any detail request that was still resolving for the old row.
func clearSelection() {
activeDetailLoadToken += 1
selectedCapture = nil
}
/// An in-place account switch only posts `runtimeOwnerDidChange`. Fence all
/// in-flight work and discard the previous owner's source-scoped projection
/// before the next appearance reloads it for the new owner.
private func resetForRuntimeOwnerChange() {
activeDetailLoadToken += 1
activeListLoadToken += 1
hasLoaded = false
captures = []
selectedCapture = nil
count = nil
isLoading = false
isLoadingMore = false
errorMessage = nil
}
/// Detail always revalidates from the source-scoped list's selected capture.
/// It never falls back to a generic list request if the detail read fails.
func loadDetail(id: String) async -> ServerConversation? {
let token = beginDetailLoad()
if let cached = try? await local.detail(id: id), cached.isOmiCaptureArchiveRecord {
guard token == activeDetailLoadToken else { return nil }
selectedCapture = cached
}
do {
let detail = try await remote.detail(id: id)
guard token == activeDetailLoadToken else { return nil }
guard detail.isOmiCaptureArchiveRecord else {
guard token == activeDetailLoadToken else { return nil }
errorMessage = "This capture is no longer available."
return nil
}
selectedCapture = detail
if let index = captures.firstIndex(where: { $0.id == detail.id }) {
captures[index] = detail
} else {
captures.insert(detail, at: 0)
}
try? await local.store(detail)
return detail
} catch {
guard token == activeDetailLoadToken else { return nil }
errorMessage = "This Omi-device capture is unavailable. Refresh to try again."
return nil
}
}
private func reloadFirstPage(query: CaptureArchiveQuery, token: Int) async {
do {
async let remoteRows = remote.list(query: query)
async let remoteCount = remote.count(query: query)
let (unvalidatedRows, remoteTotal) = try await (remoteRows, remoteCount)
guard token == activeListLoadToken else { return }
let rows = try validatedArchiveRows(unvalidatedRows)
captures = rows
if let selectedCapture {
// Keep the selected value coherent with the server-authoritative page.
// A removed capture must not remain open as stale local state.
self.selectedCapture = rows.first(where: { $0.id == selectedCapture.id })
}
count = remoteTotal
errorMessage = nil
for capture in rows {
try? await local.store(capture)
}
} catch {
guard token == activeListLoadToken else { return }
// Cache rows may remain visible, but the state is never silently healthy:
// archive data is unavailable rather than silently replaced with a mixed list.
errorMessage = "Omi-device captures are unavailable. Refresh to try again."
}
}
private func beginDetailLoad() -> Int {
activeDetailLoadToken += 1
return activeDetailLoadToken
}
private func beginListLoad() -> Int {
activeListLoadToken += 1
return activeListLoadToken
}
private func validatedArchiveRows(_ rows: [ServerConversation]) throws -> [ServerConversation] {
guard rows.allSatisfy(\.isOmiCaptureArchiveRecord) else {
throw CaptureArchiveRepositoryError.receivedNonArchiveCapture
}
return rows
}
}