返回 DeepSeek-Reasonix
reconcile_integrity.go
根目录 / internal / sessioncatalog / reconcile_integrity.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "database/sql"
6 "errors"
7 )
8
9 const (
10 repairReasonPathMismatch = "path_mismatch"
11 repairReasonScopeMismatch = "scope_mismatch"
12 repairReasonTopicMismatch = "topic_mismatch"
13 )
14
15 // directoryScanCanSkip is deliberately stricter than a row-count check. The
16 // directory signature tells us that the source entries did not change, but an
17 // older catalog may still contain the wrong rows for the same count. Compare
18 // the authoritative path/sidecar projection before trusting the ready marker.
19 func (c *Catalog) directoryScanCanSkip(ctx context.Context, target DirectoryTarget, signature string) (bool, error) {
20 path := target.Path
21 directoryKey := c.pathKey(path)
22 target.Scope, target.WorkspaceRoot = normalizeScope(target.Scope, target.WorkspaceRoot)
23 if c.directoryVerified(target, signature) {
24 return true, nil
25 }
26 var expectedTotal, present, unprojected, missing int
27 var storedScope, storedRoot string
28 err := c.db.QueryRowContext(ctx, `SELECT scope,workspace_root,total,
29 (SELECT COUNT(*) FROM catalog_sessions WHERE directory_key=? AND missing_since=0),
30 (SELECT COUNT(*) FROM catalog_sessions s
31 WHERE s.directory_key=? AND s.missing_since=0 AND s.topic_id<>''
32 AND NOT EXISTS (
33 SELECT 1 FROM catalog_topics t
34 WHERE t.scope=s.scope AND t.workspace_root_key=s.workspace_root_key AND t.topic_id=s.topic_id
35 )),
36 (SELECT COUNT(*) FROM catalog_sessions WHERE directory_key=? AND missing_since>0)
37 FROM catalog_directories
38 WHERE path_key=? AND signature=? AND state='ready'`,
39 directoryKey, directoryKey, directoryKey, directoryKey, signature).Scan(&storedScope, &storedRoot, &expectedTotal, &present, &unprojected, &missing)
40 if errors.Is(err, sql.ErrNoRows) {
41 return false, nil
42 }
43 if err != nil {
44 return false, err
45 }
46 if storedScope != target.Scope ||
47 c.workspaceRootKey(storedScope, storedRoot) != c.workspaceRootKey(target.Scope, target.WorkspaceRoot) {
48 c.markRepair(repairReasonScopeMismatch, c.opts.Now().UnixMilli())
49 return false, nil
50 }
51 content := newStrictRecoveryContentCache(c.testSessionContentLoadHook)
52 ordered, err := listSessionOrderWithContent(path, content)
53 if err != nil {
54 return false, err
55 }
56 expectedRecords := make([]SessionRecord, 0, len(ordered))
57 for _, info := range ordered {
58 expectedRecords = append(expectedRecords, normalizeSessionRecord(recordFromOrder(target, info)))
59 }
60 expectedRecords, err = c.preserveKnownSourceStates(ctx, path, expectedRecords)
61 if err != nil {
62 return false, err
63 }
64 for i := range expectedRecords {
65 expectedRecords[i] = classifyRecoveryLineageWithContent(expectedRecords[i], content)
66 }
67 expectedRecords = promoteCanonicalLeavesWithContent(expectedRecords, content)
68 expected := make(map[string]SessionRecord, len(expectedRecords))
69 for _, record := range expectedRecords {
70 expected[c.pathKey(record.Path)] = record
71 }
72 if expectedTotal != len(expected) || present != len(expected) {
73 c.markRepair(repairReasonPathMismatch, c.opts.Now().UnixMilli())
74 return false, nil
75 }
76 rows, err := c.db.QueryContext(ctx, `SELECT path,directory,scope,workspace_root,
77 topic_id,topic_title,custom_title,created_at,last_activity_at,preview,
78 turns,turns_state,recovered,recovery_reason,recovery_digest,parent_id,
79 recovery_copy,recovery_group_id,recovery_role,recovery_canonical,
80 logical_topic_id,ordinary_visible,content_fingerprint,meta_fingerprint,health
81 FROM catalog_sessions WHERE directory_key=? AND missing_since=0`, directoryKey)
82 if err != nil {
83 return false, err
84 }
85 seen := make(map[string]struct{}, len(expected))
86 for rows.Next() {
87 var got SessionRecord
88 var recovered, recoveryCopy, recoveryCanonical, ordinaryVisible int
89 if err := rows.Scan(&got.Path, &got.Directory, &got.Scope, &got.WorkspaceRoot,
90 &got.TopicID, &got.TopicTitle, &got.CustomTitle, &got.CreatedAt,
91 &got.LastActivityAt, &got.Preview, &got.Turns, &got.TurnsState,
92 &recovered, &got.RecoveryReason, &got.RecoveryDigest, &got.ParentID,
93 &recoveryCopy, &got.RecoveryGroupID, &got.RecoveryRole, &recoveryCanonical,
94 &got.LogicalTopicID, &ordinaryVisible, &got.ContentFingerprint,
95 &got.MetaFingerprint, &got.Health); err != nil {
96 _ = rows.Close()
97 return false, err
98 }
99 got.Recovered = recovered != 0
100 got.RecoveryCopy = recoveryCopy != 0
101 got.RecoveryCanonical = recoveryCanonical != 0
102 got.OrdinaryVisible = ordinaryVisible != 0
103 gotKey := c.pathKey(got.Path)
104 want, ok := expected[gotKey]
105 if !ok {
106 _ = rows.Close()
107 c.markRepair(repairReasonPathMismatch, c.opts.Now().UnixMilli())
108 return false, nil
109 }
110 seen[gotKey] = struct{}{}
111 if !sameSessionProjection(got, want) {
112 _ = rows.Close()
113 c.markRepair(repairReasonTopicMismatch, c.opts.Now().UnixMilli())
114 return false, nil
115 }
116 }
117 if err := rows.Err(); err != nil {
118 _ = rows.Close()
119 return false, err
120 }
121 if err := rows.Close(); err != nil {
122 return false, err
123 }
124 if len(seen) != len(expected) {
125 c.markRepair(repairReasonPathMismatch, c.opts.Now().UnixMilli())
126 return false, nil
127 }
128 if unprojected != 0 || missing != 0 {
129 c.markRepair(repairReasonTopicMismatch, c.opts.Now().UnixMilli())
130 return false, nil
131 }
132 c.markDirectoryVerified(target, signature)
133 return true, nil
134 }
135
136 func (c *Catalog) directoryVerified(target DirectoryTarget, signature string) bool {
137 key := c.verifiedDirectoryKey(target)
138 c.verifiedDirsMu.RLock()
139 verified := c.verifiedDirs[key] == signature
140 c.verifiedDirsMu.RUnlock()
141 return verified
142 }
143
144 func (c *Catalog) markDirectoryVerified(target DirectoryTarget, signature string) {
145 c.verifiedDirsMu.Lock()
146 if c.verifiedDirs == nil {
147 c.verifiedDirs = map[string]string{}
148 }
149 c.verifiedDirs[c.verifiedDirectoryKey(target)] = signature
150 c.verifiedDirsMu.Unlock()
151 }
152
153 func (c *Catalog) markDirectoryVerifiedIfStable(ctx context.Context, target DirectoryTarget, signature string) {
154 var missing int
155 if err := c.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM catalog_sessions WHERE directory_key=? AND missing_since>0`,
156 c.pathKey(target.Path)).Scan(&missing); err == nil && missing == 0 {
157 c.markDirectoryVerified(target, signature)
158 }
159 }
160
161 func (c *Catalog) verifiedDirectoryKey(target DirectoryTarget) string {
162 scope, root := normalizeScope(target.Scope, target.WorkspaceRoot)
163 return c.pathKey(target.Path) + "\x00" + scope + "\x00" + c.workspaceRootKey(scope, root)
164 }
165
166 func sameSessionProjection(got, want SessionRecord) bool {
167 return got.Path == want.Path &&
168 got.Directory == want.Directory &&
169 got.Scope == want.Scope &&
170 got.WorkspaceRoot == want.WorkspaceRoot &&
171 got.TopicID == want.TopicID &&
172 got.TopicTitle == want.TopicTitle &&
173 got.CustomTitle == want.CustomTitle &&
174 got.CreatedAt == want.CreatedAt &&
175 got.LastActivityAt == want.LastActivityAt &&
176 got.Preview == want.Preview &&
177 got.Turns == want.Turns &&
178 got.TurnsState == want.TurnsState &&
179 got.Recovered == want.Recovered &&
180 got.RecoveryReason == want.RecoveryReason &&
181 got.RecoveryDigest == want.RecoveryDigest &&
182 got.ParentID == want.ParentID &&
183 got.RecoveryCopy == want.RecoveryCopy &&
184 got.RecoveryGroupID == want.RecoveryGroupID &&
185 got.RecoveryRole == want.RecoveryRole &&
186 got.RecoveryCanonical == want.RecoveryCanonical &&
187 got.LogicalTopicID == want.LogicalTopicID &&
188 got.OrdinaryVisible == want.OrdinaryVisible &&
189 got.ContentFingerprint == want.ContentFingerprint &&
190 got.MetaFingerprint == want.MetaFingerprint &&
191 got.Health == want.Health
192 }
193
194 // sameSessionIndexInput covers fields sourced directly from the authoritative
195 // transcript/branch sidecar. Derived lineage visibility is intentionally left
196 // out: reconcile owns that projection and may update it without rewriting the
197 // session row for every exact-path save.
198 func sameSessionIndexInput(got, want SessionRecord) bool {
199 return got.Path == want.Path && got.Directory == want.Directory &&
200 got.Scope == want.Scope && got.WorkspaceRoot == want.WorkspaceRoot &&
201 got.TopicID == want.TopicID && got.TopicTitle == want.TopicTitle &&
202 got.CustomTitle == want.CustomTitle && got.CreatedAt == want.CreatedAt &&
203 got.LastActivityAt == want.LastActivityAt && got.Preview == want.Preview &&
204 got.Turns == want.Turns && got.TurnsState == want.TurnsState &&
205 got.Recovered == want.Recovered && got.RecoveryReason == want.RecoveryReason &&
206 got.RecoveryDigest == want.RecoveryDigest && got.ParentID == want.ParentID &&
207 got.ContentFingerprint == want.ContentFingerprint && got.MetaFingerprint == want.MetaFingerprint &&
208 got.Health == want.Health
209 }
210
210 lines GO