using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using ClaudeDo.Data; using ClaudeDo.Worker.Online; namespace ClaudeDo.Worker.Config; public sealed class WorkerConfig { [JsonPropertyName("db_path")] public string DbPath { get; set; } = "~/.claudeDo/todo.db"; [JsonPropertyName("sandbox_root")] public string SandboxRoot { get; set; } = "~/.claudeDo/sandbox"; [JsonPropertyName("log_root")] public string LogRoot { get; set; } = "~/.claudeDo/logs"; /// "sibling" → place worktrees next to the target repo; "central" → under . [JsonPropertyName("worktree_root_strategy")] public string WorktreeRootStrategy { get; set; } = "sibling"; [JsonPropertyName("central_worktree_root")] public string CentralWorktreeRoot { get; set; } = "~/.claudeDo/worktrees"; [JsonPropertyName("queue_backstop_interval_ms")] public int QueueBackstopIntervalMs { get; set; } = 30_000; [JsonPropertyName("signalr_port")] public int SignalRPort { get; set; } = 47_821; [JsonPropertyName("claude_bin")] public string ClaudeBin { get; set; } = "claude"; /// Port for the external MCP endpoint. 0 disables the external listener entirely. [JsonPropertyName("external_mcp_port")] public int ExternalMcpPort { get; set; } = 47_822; /// Optional API key clients must pass via X-ClaudeDo-Key header. Null/empty = loopback trust only. [JsonPropertyName("external_mcp_api_key")] public string? ExternalMcpApiKey { get; set; } [JsonPropertyName("online_inbox")] public OnlineInboxConfig OnlineInbox { get; set; } = new(); /// /// Usage-monitor poll interval while at least one task is Running. Clamped to a minimum /// of 90s on load. Measured 2026-09-16 against the live endpoint: a clean burst allows /// exactly 5 requests before a 429, with Retry-After: 300 (a flat 5-req/5-min quota, /// no ratelimit-* headers). 120s leaves ~2.5 requests of headroom per 5-minute window /// for manual refreshes and any other consumer of the same OAuth token — 60s consumes the /// whole quota by itself with zero margin, which is what made the old flat-60s cadence 429 /// (see commit 2700c3d8). Same default as : the /// quota is a flat per-account limit, not tied to ClaudeDo's own activity, so an active/idle /// split buys nothing safety-wise. The two config keys stay separate for compatibility. /// [JsonPropertyName("usage_poll_interval_active_seconds")] public int UsagePollIntervalActiveSeconds { get; set; } = 120; /// /// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 90s on /// load. Same default as — see that doc for the /// measured rate limit this is sized against. /// [JsonPropertyName("usage_poll_interval_idle_seconds")] public int UsagePollIntervalIdleSeconds { get; set; } = 120; /// Base URL of the Bandel ticket-system API. Null = ticket integration off entirely. [JsonPropertyName("ticket_api_base_url")] public string? TicketApiBaseUrl { get; set; } public static string DefaultConfigPath => Path.Combine(Paths.AppDataRoot(), "worker.config.json"); /// /// Loads the config from (defaults to ). /// Missing file → returns defaults. Resolves all path-typed fields to absolute paths. /// public static WorkerConfig Load(string? path = null) { path ??= DefaultConfigPath; WorkerConfig cfg; if (File.Exists(path)) { var json = File.ReadAllText(path); cfg = JsonSerializer.Deserialize(json, JsonOpts) ?? throw new InvalidOperationException($"Failed to parse {path}"); } else { cfg = new WorkerConfig(); } cfg.DbPath = Paths.Expand(cfg.DbPath); cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot); cfg.LogRoot = Paths.Expand(cfg.LogRoot); cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot); cfg.UsagePollIntervalActiveSeconds = Math.Max(90, cfg.UsagePollIntervalActiveSeconds); cfg.UsagePollIntervalIdleSeconds = Math.Max(90, cfg.UsagePollIntervalIdleSeconds); return cfg; } /// /// Persists ONLY the online_inbox section back to /// (defaults to ) without rewriting any other fields. /// Reads the existing JSON, replaces the online_inbox node, and writes back indented. /// public void SaveOnlineInbox(string? path = null) => SaveKey("online_inbox", JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts), path); /// Persists ONLY claude_bin, same read-modify-write as /// — every other field in the file stays untouched. public void SaveClaudeBin(string? path = null) => SaveKey("claude_bin", JsonValue.Create(ClaudeBin), path); /// Persistiert NUR ticket_api_base_url, gleiches read-modify-write wie /// — jedes andere Feld der Datei bleibt unangetastet. public void SaveTicketApiBaseUrl(string? path = null) => SaveKey("ticket_api_base_url", JsonValue.Create(TicketApiBaseUrl), path); private static void SaveKey(string key, JsonNode? value, string? path) { path ??= DefaultConfigPath; var root = File.Exists(path) ? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject() : new JsonObject(); root[key] = value; Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, root.ToJsonString(WriteOpts)); } private static readonly JsonSerializerOptions JsonOpts = new() { ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; private static readonly JsonSerializerOptions InboxSerializerOpts = new() { WriteIndented = false, }; private static readonly JsonSerializerOptions WriteOpts = new() { WriteIndented = true, }; }