Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: show lane split and retry queue in `attic status`

The status command now reads local-availability from Photos.sqlite and
the retry queue from disk, then surfaces them as two new rows:

- "Lanes" under Backup: N local / M iCloud pending, so users can see
how much of the pending work runs in the fast lane vs. the throttled
iCloud lane.
- "Retries" section: queued count, max attempts, and the date of the
oldest first failure — uses the new RetryEntry schema from beta.6.

Both rows render in rich (ANSI) and plain output.

+160 -4
+2
CHANGELOG.md
··· 17 17 - `BackupProgressDelegate.concurrencyChanged(limit:)` — new delegate callback 18 18 emitted between batches whenever the controller adjusts. 19 19 - Terminal dashboard shows the current lane count next to upload speed. 20 + - `attic status` now surfaces the pending-asset lane split (local vs iCloud) 21 + and a retry-queue summary (count, max attempts, oldest firstFailedAt). 20 22 - Retry queue schema upgrade: each entry now tracks `classification`, 21 23 `attempts`, `firstFailedAt`, `lastFailedAt`, and `lastMessage`. Merging 22 24 across runs preserves `firstFailedAt` and increments `attempts`, so the
+8 -1
Sources/AtticCLI/StatusCommand.swift
··· 15 15 16 16 let library = StatusStats.computeLibraryStats(assets) 17 17 let types = StatusStats.computeUTIBreakdown(assets) 18 + let localAvailability = Dependencies.loadLocalAvailability() 19 + let retry = StatusStats.computeRetryInfo(FileRetryQueueStore().load()) 18 20 19 21 var backup: BackupStats? 20 22 var s3: S3Info? ··· 22 24 do { 23 25 let (config, _, manifestStore) = try Dependencies.makeBackupDeps() 24 26 let manifest = try await Dependencies.loadManifest(store: manifestStore) 25 - backup = StatusStats.computeBackupStats(assets: assets, manifest: manifest) 27 + backup = StatusStats.computeBackupStats( 28 + assets: assets, 29 + manifest: manifest, 30 + localAvailability: localAvailability, 31 + ) 26 32 s3 = StatusStats.computeS3Info(bucket: config.bucket, manifest: manifest) 27 33 } catch CLIError.notInitialized { 28 34 // No config — show library only with init hint ··· 34 40 backup: backup, 35 41 s3: s3, 36 42 types: types, 43 + retry: retry, 37 44 ) 38 45 39 46 StatusRenderer(isTTY: isTTY).render(data)
+27
Sources/AtticCLI/StatusRenderer.swift
··· 37 37 print(" \(bar) \(pctColor)\(String(format: "%.1f", backup.percentage))%\(reset)") 38 38 print(" Backed up \(padLeft(format(backup.backedUp), width: 8)) (\(formatBytes(backup.backedUpBytes)))") 39 39 print(" Pending \(padLeft(format(backup.pending), width: 8))") 40 + if let local = backup.pendingLocal, let cloud = backup.pendingCloud { 41 + let laneDetail = "\(format(local)) local \(dim)·\(reset) \(format(cloud)) iCloud" 42 + print(" Lanes \(laneDetail)") 43 + } 44 + } 45 + 46 + // Retry queue section 47 + if let retry = data.retry { 48 + print("") 49 + print("\(bold)Retries\(reset)") 50 + print(" Queued \(padLeft(format(retry.count), width: 8)) (max \(retry.maxAttempts) attempt\(retry.maxAttempts == 1 ? "" : "s"))") 51 + if let oldest = retry.oldestFirstFailedAt { 52 + let day = String(oldest.prefix(10)) 53 + print(" Since \(day)") 54 + } 40 55 } 41 56 42 57 // S3 section ··· 82 97 print(" Progress: \(String(format: "%.1f", backup.percentage))%") 83 98 print(" Backed up: \(format(backup.backedUp)) (\(formatBytes(backup.backedUpBytes)))") 84 99 print(" Pending: \(format(backup.pending))") 100 + if let local = backup.pendingLocal, let cloud = backup.pendingCloud { 101 + print(" Lanes: \(format(local)) local / \(format(cloud)) iCloud") 102 + } 103 + } 104 + 105 + if let retry = data.retry { 106 + print("") 107 + print("Retries") 108 + print(" Queued: \(format(retry.count)) (max \(retry.maxAttempts) attempt\(retry.maxAttempts == 1 ? "" : "s"))") 109 + if let oldest = retry.oldestFirstFailedAt { 110 + print(" Since: \(String(oldest.prefix(10)))") 111 + } 85 112 } 86 113 87 114 if let s3 = data.s3 {
+57 -3
Sources/AtticCore/StatusStats.swift
··· 19 19 public let backup: BackupStats? 20 20 public let s3: S3Info? 21 21 public let types: [TypeBreakdown] 22 + public let retry: RetryInfo? 22 23 23 24 public init( 24 25 version: String, ··· 26 27 backup: BackupStats?, 27 28 s3: S3Info?, 28 29 types: [TypeBreakdown], 30 + retry: RetryInfo? = nil, 29 31 ) { 30 32 self.version = version 31 33 self.library = library 32 34 self.backup = backup 33 35 self.s3 = s3 34 36 self.types = types 37 + self.retry = retry 35 38 } 36 39 } 37 40 ··· 58 61 public let backedUpBytes: Int 59 62 public let pending: Int 60 63 public let total: Int 64 + /// Pending assets whose originals are cached locally — these run in the 65 + /// fast lane at full concurrency. Nil when local availability couldn't 66 + /// be determined (Photos.sqlite unreadable). 67 + public let pendingLocal: Int? 61 68 62 69 public var percentage: Double { 63 70 total == 0 ? 100.0 : Double(backedUp) / Double(total) * 100 64 71 } 65 72 66 - public init(backedUp: Int, backedUpBytes: Int, pending: Int, total: Int) { 73 + /// Pending assets that aren't cached locally and must be downloaded from 74 + /// iCloud first — these run in the throttled lane. 75 + public var pendingCloud: Int? { 76 + pendingLocal.map { pending - $0 } 77 + } 78 + 79 + public init( 80 + backedUp: Int, 81 + backedUpBytes: Int, 82 + pending: Int, 83 + total: Int, 84 + pendingLocal: Int? = nil, 85 + ) { 67 86 self.backedUp = backedUp 68 87 self.backedUpBytes = backedUpBytes 69 88 self.pending = pending 70 89 self.total = total 90 + self.pendingLocal = pendingLocal 91 + } 92 + } 93 + 94 + /// Retry-queue summary for the status dashboard. 95 + public struct RetryInfo: Sendable, Equatable { 96 + public let count: Int 97 + public let maxAttempts: Int 98 + public let oldestFirstFailedAt: String? 99 + 100 + public init(count: Int, maxAttempts: Int, oldestFirstFailedAt: String?) { 101 + self.count = count 102 + self.maxAttempts = maxAttempts 103 + self.oldestFirstFailedAt = oldestFirstFailedAt 71 104 } 72 105 } 73 106 ··· 130 163 return result 131 164 } 132 165 133 - public static func computeBackupStats(assets: [AssetInfo], manifest: Manifest) -> BackupStats { 134 - var backedUp = 0, backedUpBytes = 0 166 + public static func computeBackupStats( 167 + assets: [AssetInfo], 168 + manifest: Manifest, 169 + localAvailability: (any LocalAvailabilityProviding)? = nil, 170 + ) -> BackupStats { 171 + var backedUp = 0, backedUpBytes = 0, pendingLocal = 0 135 172 for asset in assets { 136 173 if let entry = manifest.entries[asset.uuid] { 137 174 backedUp += 1 138 175 backedUpBytes += entry.size ?? 0 176 + } else if localAvailability?.isLocallyAvailable(uuid: asset.uuid) == true { 177 + pendingLocal += 1 139 178 } 140 179 } 141 180 return BackupStats( ··· 143 182 backedUpBytes: backedUpBytes, 144 183 pending: assets.count - backedUp, 145 184 total: assets.count, 185 + pendingLocal: localAvailability == nil ? nil : pendingLocal, 186 + ) 187 + } 188 + 189 + public static func computeRetryInfo(_ queue: RetryQueue?) -> RetryInfo? { 190 + guard let queue, !queue.entries.isEmpty else { return nil } 191 + let maxAttempts = queue.entries.map(\.attempts).max() ?? 0 192 + let oldest = queue.entries 193 + .map(\.firstFailedAt) 194 + .filter { !$0.isEmpty } 195 + .min() 196 + return RetryInfo( 197 + count: queue.entries.count, 198 + maxAttempts: maxAttempts, 199 + oldestFirstFailedAt: oldest, 146 200 ) 147 201 } 148 202
+66
Tests/AtticCoreTests/StatusStatsTests.swift
··· 160 160 #expect(stats.total == 0) 161 161 } 162 162 163 + @Test("Pending lane split counts only not-yet-backed-up locals") 164 + func backupStatsLaneSplit() { 165 + let assets = [ 166 + makeAsset(uuid: "a"), // local, not backed up → pendingLocal 167 + makeAsset(uuid: "b"), // cloud-only, not backed up → pendingCloud 168 + makeAsset(uuid: "c"), // local but already backed up → doesn't count 169 + ] 170 + let manifest = Manifest(entries: [ 171 + "c": ManifestEntry(uuid: "c", s3Key: "k", checksum: "x", backedUpAt: "2026-01-01"), 172 + ]) 173 + let availability = PhotosDatabaseLocalAvailability(localUUIDs: ["a", "c"]) 174 + 175 + let stats = StatusStats.computeBackupStats( 176 + assets: assets, 177 + manifest: manifest, 178 + localAvailability: availability, 179 + ) 180 + 181 + #expect(stats.pending == 2) 182 + #expect(stats.pendingLocal == 1) 183 + #expect(stats.pendingCloud == 1) 184 + } 185 + 186 + @Test("Lane split stays nil when availability can't be determined") 187 + func backupStatsLaneSplitNilWithoutAvailability() { 188 + let assets = [makeAsset(uuid: "a")] 189 + let stats = StatusStats.computeBackupStats(assets: assets, manifest: Manifest()) 190 + #expect(stats.pendingLocal == nil) 191 + #expect(stats.pendingCloud == nil) 192 + } 193 + 194 + // MARK: - Retry Info 195 + 196 + @Test("Retry info surfaces count, max attempts, and oldest firstFailedAt") 197 + func retryInfoSummary() { 198 + let queue = RetryQueue( 199 + entries: [ 200 + RetryEntry( 201 + uuid: "old", 202 + classification: .transientCloud, 203 + attempts: 4, 204 + firstFailedAt: "2025-01-01T00:00:00Z", 205 + lastFailedAt: "2025-01-05T00:00:00Z", 206 + ), 207 + RetryEntry( 208 + uuid: "new", 209 + classification: .other, 210 + attempts: 1, 211 + firstFailedAt: "2025-01-05T00:00:00Z", 212 + lastFailedAt: "2025-01-05T00:00:00Z", 213 + ), 214 + ], 215 + updatedAt: "2025-01-05T00:00:00Z", 216 + ) 217 + let info = StatusStats.computeRetryInfo(queue) 218 + #expect(info?.count == 2) 219 + #expect(info?.maxAttempts == 4) 220 + #expect(info?.oldestFirstFailedAt == "2025-01-01T00:00:00Z") 221 + } 222 + 223 + @Test("Retry info is nil for an empty or missing queue") 224 + func retryInfoNilWhenEmpty() { 225 + #expect(StatusStats.computeRetryInfo(nil) == nil) 226 + #expect(StatusStats.computeRetryInfo(RetryQueue(entries: [], updatedAt: "")) == nil) 227 + } 228 + 163 229 // MARK: - S3 Info 164 230 165 231 @Test func s3InfoDerivesLastBackup() {