using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Data.Sqlite; namespace CodexProviderSync.Core; public sealed class GlobalStateService { private readonly SqliteStateService _sqliteStateService = new(); public string StatePath(string codexHome) { return Path.Combine(codexHome, AppConstants.GlobalStateFileBasename); } public string BackupPath(string codexHome) { return Path.Combine(codexHome, AppConstants.GlobalStateBackupFileBasename); } public async Task> ReadThreadCwdStatsAsync(string codexHome) { string? dbPath = _sqliteStateService.ExistingStateDbPath(codexHome); if (dbPath is null) { return []; } try { await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly); await connection.OpenAsync(); HashSet columns = await ReadThreadTableColumnsAsync(connection); if (!columns.Contains("cwd")) { return []; } string updatedAtExpression = columns.Contains("updated_at_ms") ? (columns.Contains("updated_at") ? "COALESCE(MAX(updated_at_ms), MAX(updated_at) * 1000, 0)" : "COALESCE(MAX(updated_at_ms), 0)") : (columns.Contains("updated_at") ? "COALESCE(MAX(updated_at) * 1000, 0)" : "0"); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = $""" SELECT cwd, COUNT(*) AS count, {updatedAtExpression} AS updated_at_ms FROM threads WHERE cwd IS NOT NULL AND cwd <> '' GROUP BY cwd ORDER BY count DESC, updated_at_ms DESC, cwd """; List rows = []; await using SqliteDataReader reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { string cwd = reader.GetString(0); string? normalized = NormalizeComparablePath(cwd); if (string.IsNullOrWhiteSpace(normalized)) { continue; } rows.Add(new ThreadCwdStat { Cwd = cwd, NormalizedCwd = normalized, Count = reader.GetInt64(1), UpdatedAtMs = reader.GetInt64(2) }); } return rows; } catch (Exception error) { throw SqliteStateService.WrapSqliteMalformedError( SqliteStateService.WrapSqliteBusyError(error, "update session provider metadata"), "update session provider metadata"); } } public async Task SyncWorkspaceRootsAsync( string codexHome, IReadOnlyList? cwdStats = null) { string statePath = StatePath(codexHome); if (!File.Exists(statePath)) { return new WorkspaceRootSyncResult { Present = false, Updated = false, UpdatedWorkspaceRoots = 0, SavedWorkspaceRootCount = 0 }; } JsonObject state = JsonNode.Parse(await File.ReadAllTextAsync(statePath))?.AsObject() ?? throw new InvalidOperationException($"Global state file is invalid: {statePath}"); IReadOnlyList effectiveCwdStats = cwdStats ?? await ReadThreadCwdStatsAsync(codexHome); List existingSavedRoots = ToPathList(state["electron-saved-workspace-roots"]); List existingProjectOrder = ToPathList(state["project-order"]); List existingActiveRoots = ToPathList(state["active-workspace-roots"]); JsonNode? originalActiveRoots = state["active-workspace-roots"]?.DeepClone(); JsonNode? originalLabels = state["electron-workspace-root-labels"]?.DeepClone(); JsonNode? originalOpenTargets = state["open-in-target-preferences"]?.DeepClone(); IEnumerable savedRootCandidates = existingProjectOrder.Count > 0 ? existingProjectOrder.Concat(existingSavedRoots).Concat(existingActiveRoots) : existingSavedRoots.Concat(existingActiveRoots); List nextSavedRoots = DedupePaths( savedRootCandidates.Select(value => ResolveStoredPath(value, effectiveCwdStats))); IEnumerable projectOrderCandidates = existingProjectOrder.Count > 0 ? existingProjectOrder.Concat(existingSavedRoots) : nextSavedRoots; List nextProjectOrder = DedupePaths( projectOrderCandidates.Select(value => ResolveStoredPath(value, effectiveCwdStats))); List nextActiveRoots = DedupePaths(existingActiveRoots.Select(value => ResolveStoredPath(value, effectiveCwdStats))); JsonNode? nextActiveRootsNode = state["active-workspace-roots"] is JsonArray ? ToJsonArray(nextActiveRoots) : (nextActiveRoots.Count > 0 ? JsonValue.Create(nextActiveRoots[0]) : null); JsonObject? nextLabels = CopyResolvedObjectKeys(state["electron-workspace-root-labels"] as JsonObject, effectiveCwdStats); JsonObject? nextOpenTargets = state["open-in-target-preferences"] as JsonObject; if (nextOpenTargets is not null) { nextOpenTargets = (JsonObject)nextOpenTargets.DeepClone(); if (nextOpenTargets["perPath"] is JsonObject perPath) { nextOpenTargets["perPath"] = CopyResolvedObjectKeys(perPath, effectiveCwdStats); } } bool savedRootsChanged = !existingSavedRoots.SequenceEqual(nextSavedRoots, StringComparer.Ordinal); bool projectOrderChanged = !existingProjectOrder.SequenceEqual(nextProjectOrder, StringComparer.Ordinal); bool activeRootsChanged = !JsonNode.DeepEquals(originalActiveRoots, nextActiveRootsNode); bool labelsChanged = !JsonNode.DeepEquals(originalLabels, nextLabels); bool openTargetsChanged = !JsonNode.DeepEquals(originalOpenTargets, nextOpenTargets); bool backupMissing = !File.Exists(BackupPath(codexHome)); state["electron-saved-workspace-roots"] = ToJsonArray(nextSavedRoots); state["project-order"] = ToJsonArray(nextProjectOrder); state["active-workspace-roots"] = nextActiveRootsNode; if (nextLabels is not null) { state["electron-workspace-root-labels"] = nextLabels; } if (nextOpenTargets is not null) { state["open-in-target-preferences"] = nextOpenTargets; } bool updated = savedRootsChanged || projectOrderChanged || activeRootsChanged || labelsChanged || openTargetsChanged || backupMissing; if (updated) { string json = state.ToJsonString(JsonOptions()) + Environment.NewLine; await File.WriteAllTextAsync(statePath, json); await File.WriteAllTextAsync(BackupPath(codexHome), json); } return new WorkspaceRootSyncResult { Present = true, Updated = updated, UpdatedWorkspaceRoots = CountArrayChanges(existingSavedRoots, nextSavedRoots), SavedWorkspaceRootCount = nextSavedRoots.Count }; } public async Task> ReadProjectThreadVisibilityAsync( string codexHome, int pageSize = 50) { string statePath = StatePath(codexHome); if (!File.Exists(statePath)) { return []; } JsonObject state = JsonNode.Parse(await File.ReadAllTextAsync(statePath))?.AsObject() ?? throw new InvalidOperationException($"Global state file is invalid: {statePath}"); List roots = ReadWorkspaceRootsFromState(state); if (roots.Count == 0) { return []; } string? dbPath = _sqliteStateService.ExistingStateDbPath(codexHome); if (dbPath is null) { return roots .Select(static root => new ProjectThreadVisibility { Root = root, InteractiveThreads = 0, FirstPageThreads = 0, ExactCwdMatches = 0, VerbatimCwdRows = 0, Ranks = [], RankPreview = string.Empty, ProviderCounts = new Dictionary(StringComparer.Ordinal) }) .ToList(); } try { await using SqliteConnection connection = OpenConnection(dbPath, SqliteOpenMode.ReadOnly); await connection.OpenAsync(); HashSet columns = await ReadThreadTableColumnsAsync(connection); if (!columns.Contains("cwd")) { return []; } string sourceFilter = columns.Contains("source") ? "AND source IN ('cli', 'vscode')" : string.Empty; string archivedFilter = columns.Contains("archived") ? "AND archived = 0" : string.Empty; string firstUserFilter = columns.Contains("first_user_message") ? "AND first_user_message <> ''" : string.Empty; string providerExpression = columns.Contains("model_provider") ? "model_provider" : "'' AS model_provider"; string timeExpression = BuildTimeExpression(columns); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = $""" SELECT id, cwd, {providerExpression}, {timeExpression} AS sort_ts FROM threads WHERE cwd IS NOT NULL AND cwd <> '' {archivedFilter} {firstUserFilter} {sourceFilter} ORDER BY sort_ts DESC, id DESC """; List<(string Cwd, string DesktopCwd, string? NormalizedCwd, string Provider, int Rank)> rows = []; await using SqliteDataReader reader = await command.ExecuteReaderAsync(); int rank = 1; while (await reader.ReadAsync()) { string cwd = reader.GetString(1); string provider = reader.IsDBNull(2) || string.IsNullOrWhiteSpace(reader.GetString(2)) ? "(missing)" : reader.GetString(2); rows.Add((cwd, ToDesktopWorkspacePath(cwd), NormalizeComparablePath(cwd), provider, rank)); rank += 1; } List result = []; foreach (string root in roots) { string exactRoot = ToDesktopWorkspacePath(root); string? normalizedRoot = NormalizeComparablePath(root); List<(string Cwd, string DesktopCwd, string? NormalizedCwd, string Provider, int Rank)> matchingRows = rows .Where(row => string.Equals(row.NormalizedCwd, normalizedRoot, StringComparison.Ordinal)) .ToList(); List ranks = matchingRows.Select(static row => row.Rank).ToList(); Dictionary providerCounts = new(StringComparer.Ordinal); foreach (var (_, _, _, provider, _) in matchingRows) { providerCounts[provider] = providerCounts.GetValueOrDefault(provider) + 1; } result.Add(new ProjectThreadVisibility { Root = exactRoot, InteractiveThreads = matchingRows.Count, FirstPageThreads = ranks.Count(value => value <= pageSize), ExactCwdMatches = matchingRows.Count(row => string.Equals(row.Cwd, exactRoot, StringComparison.Ordinal)), VerbatimCwdRows = matchingRows.Count(row => row.Cwd.StartsWith(@"\\?\", StringComparison.Ordinal)), Ranks = ranks, RankPreview = FormatRankPreview(ranks), ProviderCounts = providerCounts }); } return result; } catch (Exception error) { throw SqliteStateService.WrapSqliteMalformedError( SqliteStateService.WrapSqliteBusyError(error, "read project thread visibility diagnostics"), "read project thread visibility diagnostics"); } } private static async Task> ReadThreadTableColumnsAsync(SqliteConnection connection) { HashSet columns = new(StringComparer.Ordinal); await using SqliteCommand command = connection.CreateCommand(); command.CommandText = "PRAGMA table_info(\"threads\")"; await using SqliteDataReader reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { columns.Add(reader.GetString(1)); } return columns; } private static string BuildTimeExpression(HashSet columns) { List expressions = []; if (columns.Contains("updated_at_ms")) { expressions.Add("updated_at_ms"); } if (columns.Contains("updated_at")) { expressions.Add("updated_at * 1000"); } if (columns.Contains("created_at_ms")) { expressions.Add("created_at_ms"); } if (columns.Contains("created_at")) { expressions.Add("created_at * 1000"); } expressions.Add("0"); return $"COALESCE({string.Join(", ", expressions)})"; } private static List ReadWorkspaceRootsFromState(JsonObject state) { List savedRoots = ToPathList(state["electron-saved-workspace-roots"]); List projectOrder = ToPathList(state["project-order"]); List activeRoots = ToPathList(state["active-workspace-roots"]); IEnumerable candidates = projectOrder.Count > 0 ? projectOrder.Concat(savedRoots).Concat(activeRoots) : savedRoots.Concat(activeRoots); return DedupePaths(candidates.Select(ToDesktopWorkspacePath)); } private static string FormatRankPreview(IReadOnlyList ranks, int maxCount = 12) { string preview = string.Join(", ", ranks.Take(maxCount)); int remaining = ranks.Count - Math.Min(ranks.Count, maxCount); return remaining > 0 ? $"{preview} (+{remaining} more)" : preview; } private static SqliteConnection OpenConnection(string dbPath, SqliteOpenMode mode) { SqliteConnectionStringBuilder builder = new() { DataSource = dbPath, Mode = mode, Pooling = false }; return new SqliteConnection(builder.ConnectionString); } private static string ResolveStoredPath(string value, IReadOnlyList cwdStats) { string? comparable = NormalizeComparablePath(value); if (string.IsNullOrWhiteSpace(comparable)) { return value; } ThreadCwdStat? match = cwdStats .Where(entry => string.Equals(entry.NormalizedCwd, comparable, StringComparison.Ordinal)) .OrderByDescending(entry => entry.Count) .ThenByDescending(entry => entry.UpdatedAtMs) .ThenBy(entry => entry.Cwd, StringComparer.Ordinal) .FirstOrDefault(); return ToDesktopWorkspacePath(match?.Cwd ?? value); } private static List ToPathList(JsonNode? node) { if (node is JsonArray array) { return array .Select(static entry => entry?.GetValue()) .Where(static entry => !string.IsNullOrWhiteSpace(entry)) .Cast() .ToList(); } if (node is JsonValue value) { string? text = value.GetValue(); return string.IsNullOrWhiteSpace(text) ? [] : [text]; } return []; } private static JsonArray ToJsonArray(IEnumerable values) { JsonArray array = []; foreach (string value in values) { array.Add(value); } return array; } private static List DedupePaths(IEnumerable values) { HashSet seen = new(StringComparer.Ordinal); List result = []; foreach (string value in values) { string? comparable = NormalizeComparablePath(value); if (string.IsNullOrWhiteSpace(comparable) || !seen.Add(comparable)) { continue; } result.Add(value); } return result; } private static JsonObject? CopyResolvedObjectKeys(JsonObject? source, IReadOnlyList cwdStats) { if (source is null) { return null; } JsonObject result = []; foreach ((string key, JsonNode? value) in source) { string resolved = ResolveStoredPath(key, cwdStats); if (!result.ContainsKey(resolved) || string.Equals(resolved, key, StringComparison.Ordinal)) { result[resolved] = value?.DeepClone(); } } return result; } private static string ToDesktopWorkspacePath(string value) { if (string.IsNullOrWhiteSpace(value)) { return value; } string trimmed = value.Trim(); if (trimmed.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) { return @"\\" + trimmed[8..].Replace('/', '\\'); } if (trimmed.StartsWith(@"\\?\", StringComparison.Ordinal)) { string withoutPrefix = trimmed[4..].Replace('/', '\\'); if (withoutPrefix.Length == 2 && char.IsLetter(withoutPrefix[0]) && withoutPrefix[1] == ':') { return withoutPrefix + "\\"; } return withoutPrefix; } return value; } private static string? NormalizeComparablePath(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } string normalized = value.Trim(); if (normalized.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) { normalized = @"\\" + normalized[8..]; } else if (normalized.StartsWith(@"\\?\", StringComparison.Ordinal)) { normalized = normalized[4..]; } normalized = normalized.Replace('/', '\\').TrimEnd('\\'); if (normalized.Length == 2 && char.IsLetter(normalized[0]) && normalized[1] == ':') { normalized += "\\"; } return normalized.ToLowerInvariant(); } private static int CountArrayChanges(IReadOnlyList previous, IReadOnlyList next) { int compared = Math.Max(previous.Count, next.Count); int changed = 0; for (int index = 0; index < compared; index += 1) { string? left = index < previous.Count ? previous[index] : null; string? right = index < next.Count ? next[index] : null; if (!string.Equals(left, right, StringComparison.Ordinal)) { changed += 1; } } return changed; } private static JsonSerializerOptions JsonOptions() { return new JsonSerializerOptions { WriteIndented = true }; } }