|
| 1 | +# iOS Agent/Team Avatar Persistent Cache |
| 2 | + |
| 3 | +Task: `#TASK-1345` |
| 4 | + |
| 5 | +This document is the final implementation design synthesized from design A, |
| 6 | +design B, and the task裁决. It is the durable source for the shipped iOS |
| 7 | +last-known-good avatar cache. |
| 8 | + |
| 9 | +## Problem |
| 10 | + |
| 11 | +`GaryxAgentAvatarView` currently renders a real avatar only when the current |
| 12 | +row has a usable `avatarDataUrl` and `GaryxDataURLImageCache` has a decoded |
| 13 | +`UIImage` in its volatile `NSCache`. When that cache is empty after relaunch, |
| 14 | +memory pressure, or eviction, the view immediately shows fallback content and |
| 15 | +does not self-invalidate when the async predecode later succeeds. |
| 16 | + |
| 17 | +The second failure path is structural: thread, task, and widget rows often only |
| 18 | +know `agentId` or `teamId`. If the in-memory catalog does not currently contain |
| 19 | +that id, the projector passes no `avatarDataUrl`, so the row falls directly to |
| 20 | +the placeholder even if the app previously rendered a real avatar for that id. |
| 21 | + |
| 22 | +The existing `GaryxMobileCatalogCache` persists catalog rows, including |
| 23 | +`avatarDataUrl`, but it is membership-bound and encoded as one large JSON blob. |
| 24 | +It is not an identity-keyed last-known-good image store, and it must not grow |
| 25 | +into one. |
| 26 | + |
| 27 | +## Final Architecture |
| 28 | + |
| 29 | +The cache has three one-way layers: |
| 30 | + |
| 31 | +1. `GaryxMobileCatalogCache` remains the gateway-scoped catalog snapshot. It |
| 32 | + owns current catalog membership and current row fields. |
| 33 | +2. `GaryxAvatarStore` is the new membership-independent persistent fallback |
| 34 | + truth source. Its identity is gateway scope + kind (`agent` or `team`) + id. |
| 35 | + It stores raw decoded image bytes and a small index, never base64. |
| 36 | +3. `GaryxDataURLImageCache` stays a volatile decoded-image accelerator. |
| 37 | + |
| 38 | +Catalog apply writes through to `GaryxAvatarStore`. Rendering reads live data, |
| 39 | +then persistent data, then placeholder. The render hot path never writes the |
| 40 | +persistent store and never performs disk I/O or base64/JSON work. |
| 41 | + |
| 42 | +## Identity And Scope |
| 43 | + |
| 44 | +Core defines: |
| 45 | + |
| 46 | +- `GaryxAvatarKind`: `agent` or `team` |
| 47 | +- `GaryxAvatarIdentity`: `scope`, `kind`, `id` |
| 48 | +- `GaryxAvatarStoreEntry` and `GaryxAvatarStoreIndex` |
| 49 | + |
| 50 | +The app supplies the same gateway scope token used by `scopedSettingsKey(_:)` |
| 51 | +and `currentGatewayScopeId`, so a shared agent id on two gateways cannot |
| 52 | +collide. Agent and team ids are separate namespaces. |
| 53 | + |
| 54 | +Filesystem keys must be stable and safe for ids containing `/`, `:`, spaces, |
| 55 | +or other path-hostile characters. Hash the full storage key for file names. |
| 56 | + |
| 57 | +## Core Policy |
| 58 | + |
| 59 | +Core owns all cache strategy and is UIKit-free: |
| 60 | + |
| 61 | +- Parse only `data:image/...;base64,...` candidates. |
| 62 | +- Reject empty strings, remote URLs, non-image media types, malformed base64, |
| 63 | + and per-record payloads larger than 512 KB. |
| 64 | +- Compute the content fingerprint from decoded bytes using FNV-1a, not from |
| 65 | + the raw data URL string. |
| 66 | +- Use an injected `GaryxAvatarImageValidating` protocol to decide whether live |
| 67 | + bytes are a real decodable image. App code implements it with image APIs; |
| 68 | + tests inject deterministic validators. |
| 69 | +- Resolve display priority as valid live -> stored -> placeholder. |
| 70 | +- Plan write-through batches as non-empty, valid, changed fingerprints only. |
| 71 | +- Never treat an ordinary empty or invalid refreshed avatar as a tombstone. |
| 72 | +- Delete only on explicit local delete or explicit clear-avatar intent after |
| 73 | + the gateway confirms the mutation. |
| 74 | + |
| 75 | +Core also provides `GaryxInMemoryAvatarStore` for SwiftPM tests. It implements |
| 76 | +the same no-tombstone, fingerprint, LRU, scope isolation, explicit remove, and |
| 77 | +index round-trip rules without disk or UIKit. |
| 78 | + |
| 79 | +## App Store |
| 80 | + |
| 81 | +The app target implements `GaryxAvatarDiskStore` as an actor. It stores data in |
| 82 | +the App Group container `group.com.garyx.mobile`, under an avatar-cache |
| 83 | +subdirectory that is excluded from iCloud backup. |
| 84 | + |
| 85 | +The store keeps: |
| 86 | + |
| 87 | +- One raw-byte blob per avatar identity. |
| 88 | +- One small `index.json` with no base64. |
| 89 | +- Defaults of 256 records total, 16 MB total bytes, 512 KB per record. |
| 90 | +- LRU pruning by `lastAccessAt` only. It must never prune by current catalog |
| 91 | + membership because leaving the catalog is exactly when fallback is needed. |
| 92 | + |
| 93 | +All parsing, validation, fingerprint comparison, JSON encoding, file I/O, and |
| 94 | +pruning run in the actor/off-main. Index writes are batched once per catalog |
| 95 | +apply, not once per entry. |
| 96 | + |
| 97 | +## Write Path |
| 98 | + |
| 99 | +There is one normal write path: catalog apply. |
| 100 | + |
| 101 | +The existing `agents` and `teams` didSet hooks in `GaryxMobileModel.swift` |
| 102 | +already call `predecodeAgentAvatarImages()`. The new write-through runs from |
| 103 | +that same area: the main actor assembles lightweight candidates containing |
| 104 | +scope, id, kind, and the Swift `String` data-url reference, then hands them to |
| 105 | +the store actor. The actor computes fingerprints, validates bytes, diffs the |
| 106 | +index, writes changed blobs, and prunes. |
| 107 | + |
| 108 | +Cold-start `restoreCachedCatalogState()` also feeds restored catalog rows once, |
| 109 | +so users get a first-run backfill from the pre-existing catalog snapshot. |
| 110 | + |
| 111 | +Explicit mutation rules: |
| 112 | + |
| 113 | +- Successful create/update with a valid avatar stores the new identity. |
| 114 | +- Successful update with an explicit empty avatar field removes the identity |
| 115 | + only because the local edit was an explicit clear intent. |
| 116 | +- Successful delete removes that identity. |
| 117 | +- Id changes do not silently migrate bytes. The new identity is stored from a |
| 118 | + valid response or explicitly cleared, then the old identity is removed. |
| 119 | +- Ordinary refresh rows with empty or invalid `avatarDataUrl` do not remove |
| 120 | + known-good bytes. |
| 121 | + |
| 122 | +## Render Path |
| 123 | + |
| 124 | +`GaryxAgentAvatarView` becomes a thin SwiftUI renderer: |
| 125 | + |
| 126 | +- It builds a `GaryxAvatarIdentity` from environment scope + kind + id. |
| 127 | +- It asks an environment-injected singleton `GaryxAvatarImageProvider` for a |
| 128 | + synchronous `NSCache` hit. |
| 129 | +- On a miss it shows placeholder for that single view, then runs `.task(id:)` |
| 130 | + keyed by identity and live fingerprint. |
| 131 | +- The task resolves valid live bytes first, stored bytes second, placeholder |
| 132 | + last. On success it assigns local `@State resolvedImage`. |
| 133 | +- Filling one avatar must not publish list-wide observable state. The provider |
| 134 | + exposes no per-avatar `@Published` state. |
| 135 | + |
| 136 | +When a fresh live avatar arrives, the task id changes and the view naturally |
| 137 | +promotes restored -> live. When identity changes, the previous local state is |
| 138 | +invalidated. |
| 139 | + |
| 140 | +Remote `http(s)` avatar URLs keep the existing remote-image branch and are not |
| 141 | +persisted by this task. |
| 142 | + |
| 143 | +## Presentation Helper |
| 144 | + |
| 145 | +The view must not switch on provider kinds for fallback colors or icon sizes. |
| 146 | +`GaryxProviderPresentation` in Core exposes the data the view needs: |
| 147 | + |
| 148 | +- fallback RGB background |
| 149 | +- foreground style hint |
| 150 | +- icon size factor |
| 151 | +- symbol/initials already provided today |
| 152 | + |
| 153 | +The SwiftUI view maps Core data to `Color` and font size, with no local provider |
| 154 | +kind switch table. |
| 155 | + |
| 156 | +## Widget |
| 157 | + |
| 158 | +The widget receives the same last-known-good behavior without making Core call |
| 159 | +the disk actor: |
| 160 | + |
| 161 | +- `GaryxRecentThreadsWidgetSnapshotProjector.widgetThreads(from:)` accepts |
| 162 | + `avatarFallback: [GaryxAvatarIdentity: String] = [:]`. |
| 163 | +- When a row has no current live avatar and the injected map has a fallback, |
| 164 | + the projector uses it. An empty map keeps current behavior and existing tests. |
| 165 | +- The app widget persistence actor pre-resolves missing identities from the |
| 166 | + avatar store off-main and passes the map into the pure projector. |
| 167 | +- New snapshots should avoid adding new large base64 fields. The existing |
| 168 | + `avatarDataUrl` field is kept for migration and current widget rendering, but |
| 169 | + no catalog snapshot schema changes are introduced. |
| 170 | + |
| 171 | +## Rejected Alternatives |
| 172 | + |
| 173 | +Do not extend `GaryxMobileCatalogCache` into a permanent avatar cache. Its |
| 174 | +membership lifetime conflicts with orphan-id fallback, and it rewrites one |
| 175 | +large JSON blob containing base64 data. |
| 176 | + |
| 177 | +Do not persist `NSCache` or decoded `UIImage` values. Persistence is keyed by |
| 178 | +identity and raw decoded bytes; decoded images are rebuildable acceleration. |
| 179 | + |
| 180 | +Do not decode or hit disk synchronously in SwiftUI body. The only synchronous |
| 181 | +render path is an in-memory cache lookup. |
| 182 | + |
| 183 | +Do not add gateway/router/desktop behavior. This is an iOS local continuity |
| 184 | +optimization and must not invent a new product concept. |
| 185 | + |
| 186 | +## Test Plan |
| 187 | + |
| 188 | +Primary validation is headless `GaryxMobileCore` SwiftPM tests: |
| 189 | + |
| 190 | +- Data URL parsing accepts a synthetic 1x1 PNG and rejects empty, malformed, |
| 191 | + non-image, remote URL, and oversize inputs. |
| 192 | +- FNV-1a fingerprints are stable across data-url whitespace/MIME casing and |
| 193 | + change when decoded bytes change. |
| 194 | +- Resolution returns live when live validates, stored when live is empty or |
| 195 | + invalid and a stored record exists, and placeholder only when neither exists. |
| 196 | +- Write-through writes only non-empty valid changed avatars. |
| 197 | +- Ordinary empty/invalid refresh rows do not tombstone records. |
| 198 | +- Explicit clear and successful delete remove records. |
| 199 | +- Id changes store or clear the new identity before removing the old one. |
| 200 | +- Agent/team kinds and gateway scopes do not collide. |
| 201 | +- LRU pruning obeys 256 records / 16 MB / 512 KB record bounds and bumps |
| 202 | + `lastAccessAt` on reads. It never prunes by catalog membership. |
| 203 | +- Index encode/decode round-trips and version mismatch is discarded. |
| 204 | +- Widget projection uses injected fallback for id-only rows and preserves |
| 205 | + existing nil behavior with an empty map. |
| 206 | +- Provider presentation exposes fallback styling data without view switch |
| 207 | + tables. |
| 208 | + |
| 209 | +App validation must include `xcodegen generate` after adding app-target Swift |
| 210 | +files, followed by an app-target `xcodebuild` with code signing disabled if |
| 211 | +signing is the only blocker. |
0 commit comments