返回 DeepSeek-Reasonix
topic_state_store.go
根目录 / desktop / topic_state_store.go
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
801 lines GO