| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "log/slog" |
| 11 | "maps" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "sync" |
| 16 | "time" |
| 17 | |
| 18 | "reasonix/internal/agent" |
| 19 | "reasonix/internal/config" |
| 20 | "reasonix/internal/fileutil" |
| 21 | filelock "reasonix/internal/identitylock" |
| 22 | "reasonix/internal/topicstate" |
| 23 | ) |
| 24 | |
| 25 | const ( |
| 26 | topicStateLockTimeout = 2 * time.Second |
| 27 | topicStateOperationTimeout = 5 * time.Second |
| 28 | ) |
| 29 | |
| 30 | type topicStateManager struct { |
| 31 | mu sync.Mutex |
| 32 | scopes map[string]*topicStateScope |
| 33 | now func() time.Time |
| 34 | operationContext func() (context.Context, context.CancelFunc) |
| 35 | } |
| 36 | |
| 37 | type topicStateScope struct { |
| 38 | mu sync.Mutex |
| 39 | root string |
| 40 | path string |
| 41 | store *topicstate.Store |
| 42 | } |
| 43 | |
| 44 | type legacyTopicSnapshot struct { |
| 45 | titles map[string]string |
| 46 | sources map[string]string |
| 47 | createdAts map[string]int64 |
| 48 | autoMeta map[string]json.RawMessage |
| 49 | digests [4]string |
| 50 | exists bool |
| 51 | } |
| 52 | |
| 53 | var desktopTopicState = newTopicStateManager() |
| 54 | |
| 55 | // topicLegacyWriteHookForTest injects deterministic mirror failures after the |
| 56 | // authoritative SQLite transaction has committed. Production leaves it nil. |
| 57 | var topicLegacyWriteHookForTest func(string) error |
| 58 | |
| 59 | func newTopicStateManager() *topicStateManager { |
| 60 | return &topicStateManager{ |
| 61 | scopes: map[string]*topicStateScope{}, now: time.Now, |
| 62 | operationContext: newTopicOperationContext, |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | func (m *topicStateManager) scope(workspaceRoot string) *topicStateScope { |
| 67 | workspaceRoot = normalizeProjectRoot(workspaceRoot) |
| 68 | path := config.DesktopTopicStatePath(workspaceRoot) |
| 69 | key := path |
| 70 | if key == "" { |
| 71 | key = "missing:" + workspaceRoot |
| 72 | } |
| 73 | m.mu.Lock() |
| 74 | defer m.mu.Unlock() |
| 75 | if scope := m.scopes[key]; scope != nil { |
| 76 | return scope |
| 77 | } |
| 78 | scope := &topicStateScope{root: workspaceRoot, path: path} |
| 79 | m.scopes[key] = scope |
| 80 | return scope |
| 81 | } |
| 82 | |
| 83 | func (m *topicStateManager) close() { |
| 84 | m.mu.Lock() |
| 85 | scopes := make([]*topicStateScope, 0, len(m.scopes)) |
| 86 | for _, scope := range m.scopes { |
| 87 | scopes = append(scopes, scope) |
| 88 | } |
| 89 | m.scopes = map[string]*topicStateScope{} |
| 90 | m.mu.Unlock() |
| 91 | for _, scope := range scopes { |
| 92 | scope.mu.Lock() |
| 93 | if scope.store != nil { |
| 94 | _ = scope.store.Close() |
| 95 | scope.store = nil |
| 96 | } |
| 97 | scope.mu.Unlock() |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func (m *topicStateManager) snapshot(workspaceRoot string) (topicstate.Snapshot, error) { |
| 102 | scope := m.scope(workspaceRoot) |
| 103 | scope.mu.Lock() |
| 104 | defer scope.mu.Unlock() |
| 105 | if err := m.ensureOpenLocked(scope); err != nil { |
| 106 | return topicstate.Snapshot{}, err |
| 107 | } |
| 108 | return scope.store.Snapshot(context.Background()) |
| 109 | } |
| 110 | |
| 111 | func (m *topicStateManager) mutate(workspaceRoot string, mutation func(context.Context, *topicstate.Store) (topicstate.State, error), legacyFallback func() error) error { |
| 112 | scope := m.scope(workspaceRoot) |
| 113 | scope.mu.Lock() |
| 114 | defer scope.mu.Unlock() |
| 115 | if scope.root != "" && !existingDirectory(scope.root) { |
| 116 | return fmt.Errorf("workspace root %q no longer exists", scope.root) |
| 117 | } |
| 118 | if scope.path == "" { |
| 119 | return errors.New("topic state directory is unavailable") |
| 120 | } |
| 121 | if err := os.MkdirAll(filepath.Dir(scope.path), 0o700); err != nil { |
| 122 | return err |
| 123 | } |
| 124 | lockCtx, cancelLock := context.WithTimeout(context.Background(), topicStateLockTimeout) |
| 125 | release, err := filelock.Acquire(lockCtx, scope.path+".compat.lock") |
| 126 | cancelLock() |
| 127 | if err != nil { |
| 128 | return err |
| 129 | } |
| 130 | defer release() |
| 131 | if err := m.ensureOpenAndReconcileLocked(scope); err != nil { |
| 132 | if legacyFallback != nil && legacyTopicFilesExist(scope.root) && legacyFallbackAllowed(err) { |
| 133 | slog.Warn("desktop: topic state using legacy write fallback", "scope", topicScopeKind(scope.root), "error_type", topicStateErrorType(err)) |
| 134 | return legacyFallback() |
| 135 | } |
| 136 | return err |
| 137 | } |
| 138 | // Opening/reconciling uses its own context. Start the mutation's budget only |
| 139 | // after that phase; cold SQLite startup must not hand it an expired context. |
| 140 | ctx, cancelOperation := m.operationContext() |
| 141 | defer cancelOperation() |
| 142 | if _, err := mutation(ctx, scope.store); err != nil { |
| 143 | return err |
| 144 | } |
| 145 | if err := m.mirrorIfPendingLocked(ctx, scope); err != nil { |
| 146 | // The SQLite mutation is already authoritative. Keep the pending outbox |
| 147 | // for startup/next-write repair instead of reporting a false rollback. |
| 148 | slog.Warn("desktop: topic legacy mirror pending", "scope", topicScopeKind(scope.root), "error_type", topicStateErrorType(err)) |
| 149 | } |
| 150 | return nil |
| 151 | } |
| 152 | |
| 153 | func (m *topicStateManager) ensureOpenLocked(scope *topicStateScope) error { |
| 154 | if scope.store != nil { |
| 155 | return nil |
| 156 | } |
| 157 | if scope.path == "" { |
| 158 | return errors.New("topic state directory is unavailable") |
| 159 | } |
| 160 | if err := os.MkdirAll(filepath.Dir(scope.path), 0o700); err != nil { |
| 161 | return err |
| 162 | } |
| 163 | lockCtx, cancelLock := context.WithTimeout(context.Background(), topicStateLockTimeout) |
| 164 | release, err := filelock.Acquire(lockCtx, scope.path+".compat.lock") |
| 165 | cancelLock() |
| 166 | if err != nil { |
| 167 | return err |
| 168 | } |
| 169 | defer release() |
| 170 | return m.ensureOpenAndReconcileLocked(scope) |
| 171 | } |
| 172 | |
| 173 | func (m *topicStateManager) ensureOpenAndReconcileLocked(scope *topicStateScope) error { |
| 174 | ctx := context.Background() |
| 175 | if scope.store == nil { |
| 176 | store, err := m.openTopicStoreWithRecovery(ctx, scope) |
| 177 | if err != nil { |
| 178 | return err |
| 179 | } |
| 180 | scope.store = store |
| 181 | } |
| 182 | dbSnapshot, err := scope.store.Snapshot(ctx) |
| 183 | if err != nil { |
| 184 | return err |
| 185 | } |
| 186 | // Publish a pending authoritative snapshot before inspecting legacy digests; |
| 187 | // otherwise a half-written mirror can look like an old-version edit and roll |
| 188 | // metadata back into SQLite. |
| 189 | if dbSnapshot.State.LegacyBridge && dbSnapshot.State.LegacyPendingRevision != 0 { |
| 190 | if err := m.mirrorIfPendingLocked(ctx, scope); err != nil { |
| 191 | // SQLite remains readable and authoritative. Do not reconcile the |
| 192 | // known-partial JSON snapshot, and do not divert a following mutation |
| 193 | // into the legacy-only fallback path. |
| 194 | slog.Warn("desktop: topic legacy mirror still pending", "scope", topicScopeKind(scope.root), "error_type", topicStateErrorType(err)) |
| 195 | return nil |
| 196 | } |
| 197 | dbSnapshot, err = scope.store.Snapshot(ctx) |
| 198 | if err != nil { |
| 199 | return err |
| 200 | } |
| 201 | } |
| 202 | legacy, err := readLegacyTopicSnapshot(scope.root) |
| 203 | if err != nil { |
| 204 | return err |
| 205 | } |
| 206 | if legacy.exists && !dbSnapshot.State.LegacyBridge { |
| 207 | if _, err := scope.store.SetLegacyBridge(ctx); err != nil { |
| 208 | return err |
| 209 | } |
| 210 | dbSnapshot, err = scope.store.Snapshot(ctx) |
| 211 | if err != nil { |
| 212 | return err |
| 213 | } |
| 214 | } |
| 215 | if legacy.exists && !legacyDigestsMatch(dbSnapshot.State, legacy.digests) { |
| 216 | merged := mergeLegacyTopicSnapshot(dbSnapshot.Records, legacy, deletedTopicSet()) |
| 217 | if _, err := scope.store.ReplaceAll(ctx, merged); err != nil { |
| 218 | return err |
| 219 | } |
| 220 | } |
| 221 | if err := m.mirrorIfPendingLocked(ctx, scope); err != nil { |
| 222 | // Migration/reconciliation already committed to SQLite. Keep the outbox |
| 223 | // pending and let this access use the authoritative store. |
| 224 | slog.Warn("desktop: topic legacy mirror pending after reconcile", "scope", topicScopeKind(scope.root), "error_type", topicStateErrorType(err)) |
| 225 | } |
| 226 | return nil |
| 227 | } |
| 228 | |
| 229 | func (m *topicStateManager) mirrorIfPendingLocked(ctx context.Context, scope *topicStateScope) error { |
| 230 | snapshot, err := scope.store.Snapshot(ctx) |
| 231 | if err != nil { |
| 232 | return err |
| 233 | } |
| 234 | if !snapshot.State.LegacyBridge { |
| 235 | return nil |
| 236 | } |
| 237 | deleted := deletedTopicSet() |
| 238 | pruned := false |
| 239 | for topicID := range deleted { |
| 240 | if _, ok := snapshot.Records[topicID]; ok { |
| 241 | delete(snapshot.Records, topicID) |
| 242 | pruned = true |
| 243 | } |
| 244 | } |
| 245 | if pruned { |
| 246 | if _, err := scope.store.ReplaceAll(ctx, snapshot.Records); err != nil { |
| 247 | return err |
| 248 | } |
| 249 | snapshot, err = scope.store.Snapshot(ctx) |
| 250 | if err != nil { |
| 251 | return err |
| 252 | } |
| 253 | } |
| 254 | if snapshot.State.LegacyPendingRevision == 0 { |
| 255 | legacy, err := readLegacyTopicSnapshot(scope.root) |
| 256 | if err != nil { |
| 257 | return err |
| 258 | } |
| 259 | if legacyDigestsMatch(snapshot.State, legacy.digests) { |
| 260 | return nil |
| 261 | } |
| 262 | } |
| 263 | digests, err := writeLegacyTopicSnapshot(scope.root, snapshot.Records) |
| 264 | if err != nil { |
| 265 | return err |
| 266 | } |
| 267 | _, err = scope.store.MarkLegacyExported(ctx, snapshot.State.Revision, digests) |
| 268 | return err |
| 269 | } |
| 270 | |
| 271 | func (m *topicStateManager) setTitle(workspaceRoot, topicID, title, source string) error { |
| 272 | topicID, title, source = strings.TrimSpace(topicID), strings.TrimSpace(title), strings.TrimSpace(source) |
| 273 | return m.mutate(workspaceRoot, func(ctx context.Context, store *topicstate.Store) (topicstate.State, error) { |
| 274 | return store.Update(ctx, topicID, func(record *topicstate.Record) { |
| 275 | applyTopicTitle(record, title, source) |
| 276 | }) |
| 277 | }, func() error { return setLegacyTopicTitle(workspaceRoot, topicID, title, source) }) |
| 278 | } |
| 279 | |
| 280 | func (m *topicStateManager) setCreatedAt(workspaceRoot, topicID string, createdAt int64) error { |
| 281 | topicID = strings.TrimSpace(topicID) |
| 282 | return m.mutate(workspaceRoot, func(ctx context.Context, store *topicstate.Store) (topicstate.State, error) { |
| 283 | return store.Update(ctx, topicID, func(record *topicstate.Record) { record.CreatedAtMS = createdAt }) |
| 284 | }, func() error { return setLegacyTopicCreatedAt(workspaceRoot, topicID, createdAt) }) |
| 285 | } |
| 286 | |
| 287 | func (m *topicStateManager) setAutoMeta(workspaceRoot, topicID string, value *topicAutoTitleMeta) error { |
| 288 | topicID = strings.TrimSpace(topicID) |
| 289 | return m.mutate(workspaceRoot, func(ctx context.Context, store *topicstate.Store) (topicstate.State, error) { |
| 290 | return store.Update(ctx, topicID, func(record *topicstate.Record) { |
| 291 | if value == nil { |
| 292 | record.AutoMeta = nil |
| 293 | return |
| 294 | } |
| 295 | record.AutoMeta = mergeKnownAutoMeta(record.AutoMeta, *value) |
| 296 | }) |
| 297 | }, func() error { return setLegacyTopicAutoMeta(workspaceRoot, topicID, value) }) |
| 298 | } |
| 299 | |
| 300 | func (m *topicStateManager) delete(workspaceRoot, topicID string) error { |
| 301 | topicID = strings.TrimSpace(topicID) |
| 302 | return m.mutate(workspaceRoot, func(ctx context.Context, store *topicstate.Store) (topicstate.State, error) { |
| 303 | return store.Delete(ctx, topicID) |
| 304 | }, func() error { return deleteLegacyTopicState(workspaceRoot, topicID) }) |
| 305 | } |
| 306 | |
| 307 | func (m *topicStateManager) replaceTitles(workspaceRoot string, values map[string]string) error { |
| 308 | return m.mutate(workspaceRoot, func(ctx context.Context, store *topicstate.Store) (topicstate.State, error) { |
| 309 | return store.ReplaceTitles(ctx, values) |
| 310 | }, func() error { return writeLegacyStringMap(workspaceRoot, topicTitlesPath(workspaceRoot), values) }) |
| 311 | } |
| 312 | |
| 313 | func (m *topicStateManager) replaceSources(workspaceRoot string, values map[string]string) error { |
| 314 | return m.mutate(workspaceRoot, func(ctx context.Context, store *topicstate.Store) (topicstate.State, error) { |
| 315 | return store.ReplaceSources(ctx, values) |
| 316 | }, func() error { return writeLegacyStringMap(workspaceRoot, topicTitleSourcesPath(workspaceRoot), values) }) |
| 317 | } |
| 318 | |
| 319 | func (m *topicStateManager) mergeMissingTitleIndex(workspaceRoot string, titles, sources map[string]string) error { |
| 320 | return m.mutate(workspaceRoot, func(ctx context.Context, store *topicstate.Store) (topicstate.State, error) { |
| 321 | return store.MergeMissingTitleIndex(ctx, titles, sources, deletedTopicSet()) |
| 322 | }, func() error { |
| 323 | return mergeLegacyMissingTitleIndex(workspaceRoot, titles, sources) |
| 324 | }) |
| 325 | } |
| 326 | |
| 327 | func (m *topicStateManager) replaceCreatedAts(workspaceRoot string, values map[string]int64) error { |
| 328 | return m.mutate(workspaceRoot, func(ctx context.Context, store *topicstate.Store) (topicstate.State, error) { |
| 329 | return store.ReplaceCreatedAts(ctx, values) |
| 330 | }, func() error { return writeLegacyInt64Map(workspaceRoot, topicCreatedAtsPath(workspaceRoot), values) }) |
| 331 | } |
| 332 | |
| 333 | func loadTopicTitles(workspaceRoot string) map[string]string { |
| 334 | values := map[string]string{} |
| 335 | snapshot, err := desktopTopicState.snapshot(workspaceRoot) |
| 336 | if err != nil { |
| 337 | legacy, legacyErr := loadLegacyStringMap(topicTitlesPath(workspaceRoot)) |
| 338 | logTopicStateReadFallback(workspaceRoot, err, legacyErr, legacyTopicFilesExist(workspaceRoot)) |
| 339 | for id, title := range legacy { |
| 340 | values[id] = agent.UserPreviewText(title) |
| 341 | } |
| 342 | return values |
| 343 | } |
| 344 | for id, record := range snapshot.Records { |
| 345 | if record.Title != "" { |
| 346 | values[id] = agent.UserPreviewText(record.Title) |
| 347 | } |
| 348 | } |
| 349 | return values |
| 350 | } |
| 351 | |
| 352 | func loadTopicTitleSources(workspaceRoot string) map[string]string { |
| 353 | values := map[string]string{} |
| 354 | snapshot, err := desktopTopicState.snapshot(workspaceRoot) |
| 355 | if err != nil { |
| 356 | legacy, legacyErr := loadLegacyStringMap(topicTitleSourcesPath(workspaceRoot)) |
| 357 | logTopicStateReadFallback(workspaceRoot, err, legacyErr, legacyTopicFilesExist(workspaceRoot)) |
| 358 | return legacy |
| 359 | } |
| 360 | for id, record := range snapshot.Records { |
| 361 | if record.TitleSource != "" { |
| 362 | values[id] = record.TitleSource |
| 363 | } |
| 364 | } |
| 365 | return values |
| 366 | } |
| 367 | |
| 368 | func loadTopicCreatedAts(workspaceRoot string) map[string]int64 { |
| 369 | values := map[string]int64{} |
| 370 | snapshot, err := desktopTopicState.snapshot(workspaceRoot) |
| 371 | if err != nil { |
| 372 | legacy, legacyErr := loadLegacyInt64Map(topicCreatedAtsPath(workspaceRoot)) |
| 373 | logTopicStateReadFallback(workspaceRoot, err, legacyErr, legacyTopicFilesExist(workspaceRoot)) |
| 374 | return legacy |
| 375 | } |
| 376 | for id, record := range snapshot.Records { |
| 377 | if record.CreatedAtMS > 0 { |
| 378 | values[id] = record.CreatedAtMS |
| 379 | } |
| 380 | } |
| 381 | return values |
| 382 | } |
| 383 | |
| 384 | func loadTopicAutoTitleMeta(workspaceRoot string) map[string]topicAutoTitleMeta { |
| 385 | values := map[string]topicAutoTitleMeta{} |
| 386 | snapshot, err := desktopTopicState.snapshot(workspaceRoot) |
| 387 | if err != nil { |
| 388 | legacy, legacyErr := loadLegacyAutoMetaMap(topicAutoTitleMetaPath(workspaceRoot)) |
| 389 | logTopicStateReadFallback(workspaceRoot, err, legacyErr, legacyTopicFilesExist(workspaceRoot)) |
| 390 | return legacy |
| 391 | } |
| 392 | for id, record := range snapshot.Records { |
| 393 | var value topicAutoTitleMeta |
| 394 | if len(record.AutoMeta) > 0 && json.Unmarshal(record.AutoMeta, &value) == nil { |
| 395 | values[id] = value |
| 396 | } |
| 397 | } |
| 398 | return values |
| 399 | } |
| 400 | |
| 401 | func saveTopicTitleIndex(workspaceRoot string, titles, sources map[string]string) error { |
| 402 | if titles == nil { |
| 403 | titles = loadTopicTitles(workspaceRoot) |
| 404 | } |
| 405 | if sources == nil { |
| 406 | sources = loadTopicTitleSources(workspaceRoot) |
| 407 | } |
| 408 | return desktopTopicState.mergeMissingTitleIndex(workspaceRoot, titles, sources) |
| 409 | } |
| 410 | |
| 411 | func readLegacyTopicSnapshot(workspaceRoot string) (legacyTopicSnapshot, error) { |
| 412 | snapshot := legacyTopicSnapshot{ |
| 413 | titles: map[string]string{}, sources: map[string]string{}, |
| 414 | createdAts: map[string]int64{}, autoMeta: map[string]json.RawMessage{}, |
| 415 | } |
| 416 | paths := legacyTopicPaths(workspaceRoot) |
| 417 | for index, path := range paths { |
| 418 | data, err := readFileUTF8(path) |
| 419 | if errors.Is(err, os.ErrNotExist) { |
| 420 | continue |
| 421 | } |
| 422 | if err != nil { |
| 423 | return snapshot, err |
| 424 | } |
| 425 | snapshot.exists = true |
| 426 | snapshot.digests[index] = digestBytes(data) |
| 427 | var entries map[string]json.RawMessage |
| 428 | if err := json.Unmarshal(data, &entries); err != nil || entries == nil { |
| 429 | if err == nil { |
| 430 | err = errors.New("legacy topic file is not an object") |
| 431 | } |
| 432 | return snapshot, fmt.Errorf("decode %s: %w", filepath.Base(path), err) |
| 433 | } |
| 434 | for topicID, raw := range entries { |
| 435 | topicID = strings.TrimSpace(topicID) |
| 436 | if topicID == "" { |
| 437 | continue |
| 438 | } |
| 439 | switch index { |
| 440 | case 0: |
| 441 | var value string |
| 442 | if json.Unmarshal(raw, &value) == nil { |
| 443 | snapshot.titles[topicID] = agent.UserPreviewText(value) |
| 444 | } |
| 445 | case 1: |
| 446 | var value string |
| 447 | if json.Unmarshal(raw, &value) == nil { |
| 448 | snapshot.sources[topicID] = strings.TrimSpace(value) |
| 449 | } |
| 450 | case 2: |
| 451 | var value int64 |
| 452 | if json.Unmarshal(raw, &value) == nil && value > 0 { |
| 453 | snapshot.createdAts[topicID] = value |
| 454 | } |
| 455 | case 3: |
| 456 | var object map[string]json.RawMessage |
| 457 | if json.Unmarshal(raw, &object) == nil && object != nil { |
| 458 | snapshot.autoMeta[topicID] = append(json.RawMessage(nil), raw...) |
| 459 | } |
| 460 | } |
| 461 | } |
| 462 | } |
| 463 | return snapshot, nil |
| 464 | } |
| 465 | |
| 466 | func mergeLegacyTopicSnapshot(current map[string]topicstate.Record, legacy legacyTopicSnapshot, deleted map[string]bool) map[string]topicstate.Record { |
| 467 | merged := make(map[string]topicstate.Record, len(current)+len(legacy.titles)) |
| 468 | for id, record := range current { |
| 469 | if !deleted[id] { |
| 470 | merged[id] = record |
| 471 | } |
| 472 | } |
| 473 | ids := map[string]bool{} |
| 474 | for id := range legacy.titles { |
| 475 | ids[id] = true |
| 476 | } |
| 477 | for id := range legacy.sources { |
| 478 | ids[id] = true |
| 479 | } |
| 480 | for id := range legacy.createdAts { |
| 481 | ids[id] = true |
| 482 | } |
| 483 | for id := range legacy.autoMeta { |
| 484 | ids[id] = true |
| 485 | } |
| 486 | for id := range ids { |
| 487 | if deleted[id] { |
| 488 | delete(merged, id) |
| 489 | continue |
| 490 | } |
| 491 | record := merged[id] |
| 492 | record.TopicID = id |
| 493 | legacySource := strings.TrimSpace(legacy.sources[id]) |
| 494 | legacyTitle := strings.TrimSpace(legacy.titles[id]) |
| 495 | if legacyTitle != "" && (legacySource != topicTitleSourceAuto || record.TitleSource != topicTitleSourceManual) { |
| 496 | record.Title = legacyTitle |
| 497 | } |
| 498 | if legacySource != "" && !(legacySource == topicTitleSourceAuto && record.TitleSource == topicTitleSourceManual) { |
| 499 | record.TitleSource = legacySource |
| 500 | } |
| 501 | if createdAt := legacy.createdAts[id]; createdAt > 0 { |
| 502 | record.CreatedAtMS = createdAt |
| 503 | } |
| 504 | if raw := legacy.autoMeta[id]; len(raw) > 0 { |
| 505 | record.AutoMeta = mergeLegacyAutoMeta(record.AutoMeta, raw) |
| 506 | } |
| 507 | merged[id] = record |
| 508 | } |
| 509 | return merged |
| 510 | } |
| 511 | |
| 512 | func writeLegacyTopicSnapshot(workspaceRoot string, records map[string]topicstate.Record) ([4]string, error) { |
| 513 | var digests [4]string |
| 514 | titles := map[string]string{} |
| 515 | sources := map[string]string{} |
| 516 | created := map[string]int64{} |
| 517 | auto := map[string]json.RawMessage{} |
| 518 | for id, record := range records { |
| 519 | if strings.TrimSpace(record.Title) != "" { |
| 520 | titles[id] = record.Title |
| 521 | } |
| 522 | if strings.TrimSpace(record.TitleSource) != "" { |
| 523 | sources[id] = record.TitleSource |
| 524 | } |
| 525 | if record.CreatedAtMS > 0 { |
| 526 | created[id] = record.CreatedAtMS |
| 527 | } |
| 528 | if len(record.AutoMeta) > 0 { |
| 529 | auto[id] = append(json.RawMessage(nil), record.AutoMeta...) |
| 530 | } |
| 531 | } |
| 532 | values := []any{titles, sources, created, auto} |
| 533 | for index, path := range legacyTopicPaths(workspaceRoot) { |
| 534 | data, err := json.MarshalIndent(values[index], "", " ") |
| 535 | if err != nil { |
| 536 | return digests, err |
| 537 | } |
| 538 | if err := ensureLegacyTopicStateDir(workspaceRoot, path); err != nil { |
| 539 | return digests, err |
| 540 | } |
| 541 | if topicLegacyWriteHookForTest != nil { |
| 542 | if err := topicLegacyWriteHookForTest(path); err != nil { |
| 543 | return digests, err |
| 544 | } |
| 545 | } |
| 546 | if err := fileutil.AtomicWriteFile(path, data, 0o600); err != nil { |
| 547 | return digests, err |
| 548 | } |
| 549 | digests[index] = digestBytes(data) |
| 550 | } |
| 551 | return digests, nil |
| 552 | } |
| 553 | |
| 554 | func digestBytes(data []byte) string { |
| 555 | sum := sha256.Sum256(data) |
| 556 | return hex.EncodeToString(sum[:]) |
| 557 | } |
| 558 | |
| 559 | func legacyDigestsMatch(state topicstate.State, digests [4]string) bool { |
| 560 | return state.LegacyTitlesDigest == digests[0] && |
| 561 | state.LegacySourcesDigest == digests[1] && |
| 562 | state.LegacyCreatedAtsDigest == digests[2] && |
| 563 | state.LegacyAutoMetaDigest == digests[3] |
| 564 | } |
| 565 | |
| 566 | func deletedTopicSet() map[string]bool { |
| 567 | deleted := loadProjectsFile().DeletedTopics |
| 568 | set := make(map[string]bool, len(deleted)) |
| 569 | for _, topicID := range deleted { |
| 570 | set[strings.TrimSpace(topicID)] = true |
| 571 | } |
| 572 | return set |
| 573 | } |
| 574 | |
| 575 | func mergeKnownAutoMeta(existing json.RawMessage, value topicAutoTitleMeta) json.RawMessage { |
| 576 | fields := map[string]json.RawMessage{} |
| 577 | _ = json.Unmarshal(existing, &fields) |
| 578 | known, _ := json.Marshal(value) |
| 579 | var knownFields map[string]json.RawMessage |
| 580 | _ = json.Unmarshal(known, &knownFields) |
| 581 | for _, key := range []string{"stage", "userTurns", "basisHash", "updatedAt"} { |
| 582 | delete(fields, key) |
| 583 | } |
| 584 | maps.Copy(fields, knownFields) |
| 585 | data, _ := json.Marshal(fields) |
| 586 | return data |
| 587 | } |
| 588 | |
| 589 | func ensureLegacyTopicStateDir(workspaceRoot, path string) error { |
| 590 | if root := strings.TrimSpace(workspaceRoot); root != "" && !existingDirectory(root) { |
| 591 | return fmt.Errorf("workspace root %q no longer exists", root) |
| 592 | } |
| 593 | return os.MkdirAll(filepath.Dir(path), 0o700) |
| 594 | } |
| 595 | |
| 596 | func writeLegacyStringMap(workspaceRoot, path string, values map[string]string) error { |
| 597 | data, err := json.MarshalIndent(values, "", " ") |
| 598 | if err != nil { |
| 599 | return err |
| 600 | } |
| 601 | if err := ensureLegacyTopicStateDir(workspaceRoot, path); err != nil { |
| 602 | return err |
| 603 | } |
| 604 | return fileutil.AtomicWriteFile(path, data, 0o600) |
| 605 | } |
| 606 | |
| 607 | func writeLegacyInt64Map(workspaceRoot, path string, values map[string]int64) error { |
| 608 | data, err := json.MarshalIndent(values, "", " ") |
| 609 | if err != nil { |
| 610 | return err |
| 611 | } |
| 612 | if err := ensureLegacyTopicStateDir(workspaceRoot, path); err != nil { |
| 613 | return err |
| 614 | } |
| 615 | return fileutil.AtomicWriteFile(path, data, 0o600) |
| 616 | } |
| 617 | |
| 618 | func writeLegacyAutoMetaMap(workspaceRoot string, values map[string]topicAutoTitleMeta) error { |
| 619 | existing, err := loadLegacyRawAutoMetaMap(topicAutoTitleMetaPath(workspaceRoot)) |
| 620 | if err != nil { |
| 621 | return err |
| 622 | } |
| 623 | rawValues := make(map[string]json.RawMessage, len(values)) |
| 624 | for id, value := range values { |
| 625 | rawValues[id] = mergeKnownAutoMeta(existing[id], value) |
| 626 | } |
| 627 | data, err := json.MarshalIndent(rawValues, "", " ") |
| 628 | if err != nil { |
| 629 | return err |
| 630 | } |
| 631 | path := topicAutoTitleMetaPath(workspaceRoot) |
| 632 | if err := ensureLegacyTopicStateDir(workspaceRoot, path); err != nil { |
| 633 | return err |
| 634 | } |
| 635 | return fileutil.AtomicWriteFile(path, data, 0o600) |
| 636 | } |
| 637 | |
| 638 | func setLegacyTopicTitle(workspaceRoot, topicID, title, source string) error { |
| 639 | titles, err := loadLegacyStringMap(topicTitlesPath(workspaceRoot)) |
| 640 | if err != nil { |
| 641 | return err |
| 642 | } |
| 643 | sources, err := loadLegacyStringMap(topicTitleSourcesPath(workspaceRoot)) |
| 644 | if err != nil { |
| 645 | return err |
| 646 | } |
| 647 | if title == "" { |
| 648 | delete(titles, topicID) |
| 649 | delete(sources, topicID) |
| 650 | } else { |
| 651 | titles[topicID] = title |
| 652 | if source == "" { |
| 653 | delete(sources, topicID) |
| 654 | } else { |
| 655 | sources[topicID] = source |
| 656 | } |
| 657 | } |
| 658 | if err := writeLegacyStringMap(workspaceRoot, topicTitlesPath(workspaceRoot), titles); err != nil { |
| 659 | return err |
| 660 | } |
| 661 | if err := writeLegacyStringMap(workspaceRoot, topicTitleSourcesPath(workspaceRoot), sources); err != nil { |
| 662 | return err |
| 663 | } |
| 664 | if source == topicTitleSourceManual || (source == topicTitleSourceAuto && isDefaultTopicTitle(title)) { |
| 665 | return setLegacyTopicAutoMeta(workspaceRoot, topicID, nil) |
| 666 | } |
| 667 | return nil |
| 668 | } |
| 669 | |
| 670 | func setLegacyTopicCreatedAt(workspaceRoot, topicID string, createdAt int64) error { |
| 671 | values, err := loadLegacyInt64Map(topicCreatedAtsPath(workspaceRoot)) |
| 672 | if err != nil { |
| 673 | return err |
| 674 | } |
| 675 | if topicID == "" || createdAt <= 0 { |
| 676 | delete(values, topicID) |
| 677 | } else { |
| 678 | values[topicID] = createdAt |
| 679 | } |
| 680 | return writeLegacyInt64Map(workspaceRoot, topicCreatedAtsPath(workspaceRoot), values) |
| 681 | } |
| 682 | |
| 683 | func setLegacyTopicAutoMeta(workspaceRoot, topicID string, value *topicAutoTitleMeta) error { |
| 684 | values, err := loadLegacyRawAutoMetaMap(topicAutoTitleMetaPath(workspaceRoot)) |
| 685 | if err != nil { |
| 686 | return err |
| 687 | } |
| 688 | if value == nil { |
| 689 | delete(values, topicID) |
| 690 | } else { |
| 691 | values[topicID] = mergeKnownAutoMeta(values[topicID], *value) |
| 692 | } |
| 693 | data, err := json.MarshalIndent(values, "", " ") |
| 694 | if err != nil { |
| 695 | return err |
| 696 | } |
| 697 | path := topicAutoTitleMetaPath(workspaceRoot) |
| 698 | if err := ensureLegacyTopicStateDir(workspaceRoot, path); err != nil { |
| 699 | return err |
| 700 | } |
| 701 | return fileutil.AtomicWriteFile(path, data, 0o600) |
| 702 | } |
| 703 | |
| 704 | func deleteLegacyTopicState(workspaceRoot, topicID string) error { |
| 705 | titles, err := loadLegacyStringMap(topicTitlesPath(workspaceRoot)) |
| 706 | if err != nil { |
| 707 | return err |
| 708 | } |
| 709 | sources, err := loadLegacyStringMap(topicTitleSourcesPath(workspaceRoot)) |
| 710 | if err != nil { |
| 711 | return err |
| 712 | } |
| 713 | created, err := loadLegacyInt64Map(topicCreatedAtsPath(workspaceRoot)) |
| 714 | if err != nil { |
| 715 | return err |
| 716 | } |
| 717 | auto, err := loadLegacyAutoMetaMap(topicAutoTitleMetaPath(workspaceRoot)) |
| 718 | if err != nil { |
| 719 | return err |
| 720 | } |
| 721 | delete(titles, topicID) |
| 722 | delete(sources, topicID) |
| 723 | delete(created, topicID) |
| 724 | delete(auto, topicID) |
| 725 | if err := writeLegacyStringMap(workspaceRoot, topicTitlesPath(workspaceRoot), titles); err != nil { |
| 726 | return err |
| 727 | } |
| 728 | if err := writeLegacyStringMap(workspaceRoot, topicTitleSourcesPath(workspaceRoot), sources); err != nil { |
| 729 | return err |
| 730 | } |
| 731 | if err := writeLegacyInt64Map(workspaceRoot, topicCreatedAtsPath(workspaceRoot), created); err != nil { |
| 732 | return err |
| 733 | } |
| 734 | return writeLegacyAutoMetaMap(workspaceRoot, auto) |
| 735 | } |
| 736 | |
| 737 | func loadLegacyStringMap(path string) (map[string]string, error) { |
| 738 | values := map[string]string{} |
| 739 | data, err := readFileUTF8(path) |
| 740 | if errors.Is(err, os.ErrNotExist) { |
| 741 | return values, nil |
| 742 | } |
| 743 | if err != nil { |
| 744 | return nil, err |
| 745 | } |
| 746 | if err := json.Unmarshal(data, &values); err != nil || values == nil { |
| 747 | return map[string]string{}, nil |
| 748 | } |
| 749 | return values, nil |
| 750 | } |
| 751 | |
| 752 | func loadLegacyInt64Map(path string) (map[string]int64, error) { |
| 753 | values := map[string]int64{} |
| 754 | data, err := readFileUTF8(path) |
| 755 | if errors.Is(err, os.ErrNotExist) { |
| 756 | return values, nil |
| 757 | } |
| 758 | if err != nil { |
| 759 | return nil, err |
| 760 | } |
| 761 | if err := json.Unmarshal(data, &values); err != nil || values == nil { |
| 762 | return map[string]int64{}, nil |
| 763 | } |
| 764 | return values, nil |
| 765 | } |
| 766 | |
| 767 | func loadLegacyAutoMetaMap(path string) (map[string]topicAutoTitleMeta, error) { |
| 768 | values := map[string]topicAutoTitleMeta{} |
| 769 | data, err := readFileUTF8(path) |
| 770 | if errors.Is(err, os.ErrNotExist) { |
| 771 | return values, nil |
| 772 | } |
| 773 | if err != nil { |
| 774 | return nil, err |
| 775 | } |
| 776 | if err := json.Unmarshal(data, &values); err != nil || values == nil { |
| 777 | return map[string]topicAutoTitleMeta{}, nil |
| 778 | } |
| 779 | return values, nil |
| 780 | } |
| 781 | |
| 782 | func loadLegacyRawAutoMetaMap(path string) (map[string]json.RawMessage, error) { |
| 783 | values := map[string]json.RawMessage{} |
| 784 | data, err := readFileUTF8(path) |
| 785 | if errors.Is(err, os.ErrNotExist) { |
| 786 | return values, nil |
| 787 | } |
| 788 | if err != nil { |
| 789 | return nil, err |
| 790 | } |
| 791 | if err := json.Unmarshal(data, &values); err != nil || values == nil { |
| 792 | return map[string]json.RawMessage{}, nil |
| 793 | } |
| 794 | return values, nil |
| 795 | } |
| 796 | |
| 797 | func legacyFallbackAllowed(err error) bool { |
| 798 | var future *topicstate.FutureSchemaError |
| 799 | return !errors.As(err, &future) |
| 800 | } |
| 801 |