| 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 |