From f81ef02273b6dccc4febc251ffb617994ee72606 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 13 Apr 2026 12:00:47 +0200 Subject: [PATCH] feat(data,worker): add db schema init and signalr hub skeleton Data layer: Paths helper with ~/%USERPROFILE% expansion, SqliteConnectionFactory (WAL + foreign keys), SchemaInitializer that applies the embedded schema.sql, and POCO entities for lists/tasks/tags/worktrees. Worker: WorkerConfig loader (~/.todo-app/worker.config.json with defaults), WorkerHub exposing Ping(), and Program.cs wiring Kestrel to 127.0.0.1:, SignalR at /hub, schema applied on startup. Pins Microsoft.Data.Sqlite and Microsoft.Extensions.Hosting to 8.x for net8.0 compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/ClaudeDo.Data/ClaudeDo.Data.csproj | 6 +- src/ClaudeDo.Data/Models/ListEntity.cs | 10 +++ src/ClaudeDo.Data/Models/TagEntity.cs | 7 ++ src/ClaudeDo.Data/Models/TaskEntity.cs | 26 ++++++++ src/ClaudeDo.Data/Models/WorktreeEntity.cs | 21 ++++++ src/ClaudeDo.Data/Paths.cs | 31 +++++++++ src/ClaudeDo.Data/SchemaInitializer.cs | 41 ++++++++++++ src/ClaudeDo.Data/SqliteConnectionFactory.cs | 48 ++++++++++++++ src/ClaudeDo.Worker/ClaudeDo.Worker.csproj | 2 +- src/ClaudeDo.Worker/Config/WorkerConfig.cs | 70 ++++++++++++++++++++ src/ClaudeDo.Worker/Hub/WorkerHub.cs | 16 +++++ src/ClaudeDo.Worker/Program.cs | 23 ++++++- 12 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 src/ClaudeDo.Data/Models/ListEntity.cs create mode 100644 src/ClaudeDo.Data/Models/TagEntity.cs create mode 100644 src/ClaudeDo.Data/Models/TaskEntity.cs create mode 100644 src/ClaudeDo.Data/Models/WorktreeEntity.cs create mode 100644 src/ClaudeDo.Data/Paths.cs create mode 100644 src/ClaudeDo.Data/SchemaInitializer.cs create mode 100644 src/ClaudeDo.Data/SqliteConnectionFactory.cs create mode 100644 src/ClaudeDo.Worker/Config/WorkerConfig.cs create mode 100644 src/ClaudeDo.Worker/Hub/WorkerHub.cs diff --git a/src/ClaudeDo.Data/ClaudeDo.Data.csproj b/src/ClaudeDo.Data/ClaudeDo.Data.csproj index 624930a..6bb4de7 100644 --- a/src/ClaudeDo.Data/ClaudeDo.Data.csproj +++ b/src/ClaudeDo.Data/ClaudeDo.Data.csproj @@ -7,7 +7,11 @@ - + + + + + diff --git a/src/ClaudeDo.Data/Models/ListEntity.cs b/src/ClaudeDo.Data/Models/ListEntity.cs new file mode 100644 index 0000000..5c5809e --- /dev/null +++ b/src/ClaudeDo.Data/Models/ListEntity.cs @@ -0,0 +1,10 @@ +namespace ClaudeDo.Data.Models; + +public sealed class ListEntity +{ + public required string Id { get; init; } + public required string Name { get; set; } + public required DateTime CreatedAt { get; init; } + public string? WorkingDir { get; set; } + public string DefaultCommitType { get; set; } = "chore"; +} diff --git a/src/ClaudeDo.Data/Models/TagEntity.cs b/src/ClaudeDo.Data/Models/TagEntity.cs new file mode 100644 index 0000000..01c8684 --- /dev/null +++ b/src/ClaudeDo.Data/Models/TagEntity.cs @@ -0,0 +1,7 @@ +namespace ClaudeDo.Data.Models; + +public sealed class TagEntity +{ + public long Id { get; init; } + public required string Name { get; set; } +} diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs new file mode 100644 index 0000000..eef77b4 --- /dev/null +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -0,0 +1,26 @@ +namespace ClaudeDo.Data.Models; + +public enum TaskStatus +{ + Manual, + Queued, + Running, + Done, + Failed, +} + +public sealed class TaskEntity +{ + public required string Id { get; init; } + public required string ListId { get; init; } + public required string Title { get; set; } + public string? Description { get; set; } + public TaskStatus Status { get; set; } = TaskStatus.Manual; + public DateTime? ScheduledFor { get; set; } + public string? Result { get; set; } + public string? LogPath { get; set; } + public required DateTime CreatedAt { get; init; } + public DateTime? StartedAt { get; set; } + public DateTime? FinishedAt { get; set; } + public string CommitType { get; set; } = "chore"; +} diff --git a/src/ClaudeDo.Data/Models/WorktreeEntity.cs b/src/ClaudeDo.Data/Models/WorktreeEntity.cs new file mode 100644 index 0000000..6378b78 --- /dev/null +++ b/src/ClaudeDo.Data/Models/WorktreeEntity.cs @@ -0,0 +1,21 @@ +namespace ClaudeDo.Data.Models; + +public enum WorktreeState +{ + Active, + Merged, + Discarded, + Kept, +} + +public sealed class WorktreeEntity +{ + public required string TaskId { get; init; } + public required string Path { get; set; } + public required string BranchName { get; set; } + public required string BaseCommit { get; set; } + public string? HeadCommit { get; set; } + public string? DiffStat { get; set; } + public WorktreeState State { get; set; } = WorktreeState.Active; + public required DateTime CreatedAt { get; init; } +} diff --git a/src/ClaudeDo.Data/Paths.cs b/src/ClaudeDo.Data/Paths.cs new file mode 100644 index 0000000..c55037f --- /dev/null +++ b/src/ClaudeDo.Data/Paths.cs @@ -0,0 +1,31 @@ +namespace ClaudeDo.Data; + +public static class Paths +{ + /// + /// Expands a leading "~" or "%USERPROFILE%" and returns an absolute path. + /// Relative paths are resolved against (default: current directory). + /// + public static string Expand(string path, string? baseDir = null) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Path must not be empty.", nameof(path)); + + var expanded = Environment.ExpandEnvironmentVariables(path); + + if (expanded.StartsWith("~", StringComparison.Ordinal)) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + expanded = home + expanded[1..]; + } + + if (!Path.IsPathRooted(expanded)) + expanded = Path.GetFullPath(expanded, baseDir ?? Environment.CurrentDirectory); + + return Path.GetFullPath(expanded); + } + + /// ~/.todo-app — parent directory for db, logs, config, sandbox, worktrees. + public static string AppDataRoot() => + Expand("~/.todo-app"); +} diff --git a/src/ClaudeDo.Data/SchemaInitializer.cs b/src/ClaudeDo.Data/SchemaInitializer.cs new file mode 100644 index 0000000..17eff67 --- /dev/null +++ b/src/ClaudeDo.Data/SchemaInitializer.cs @@ -0,0 +1,41 @@ +using System.Reflection; +using Microsoft.Data.Sqlite; + +namespace ClaudeDo.Data; + +/// +/// Applies the embedded schema.sql script. Safe to call on every start — the script uses +/// IF NOT EXISTS / INSERT OR IGNORE. +/// +public static class SchemaInitializer +{ + private const string ResourceName = "ClaudeDo.Data.schema.sql"; + + public static void Apply(SqliteConnectionFactory factory) + { + using var conn = factory.Open(); + ApplyTo(conn); + } + + public static void ApplyTo(SqliteConnection conn) + { + var sql = LoadScript(); + using var tx = conn.BeginTransaction(); + using var cmd = conn.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + tx.Commit(); + } + + private static string LoadScript() + { + var asm = typeof(SchemaInitializer).Assembly; + using var stream = asm.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException( + $"Embedded resource '{ResourceName}' not found in {asm.GetName().Name}. " + + $"Available: {string.Join(", ", asm.GetManifestResourceNames())}"); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/src/ClaudeDo.Data/SqliteConnectionFactory.cs b/src/ClaudeDo.Data/SqliteConnectionFactory.cs new file mode 100644 index 0000000..43da6fa --- /dev/null +++ b/src/ClaudeDo.Data/SqliteConnectionFactory.cs @@ -0,0 +1,48 @@ +using Microsoft.Data.Sqlite; + +namespace ClaudeDo.Data; + +/// +/// Opens instances pointed at . +/// First call ensures the parent directory exists, enables WAL and foreign keys. +/// +public sealed class SqliteConnectionFactory +{ + public string DbPath { get; } + private readonly string _connectionString; + private int _walApplied; + + public SqliteConnectionFactory(string dbPath) + { + DbPath = Paths.Expand(dbPath); + Directory.CreateDirectory(Path.GetDirectoryName(DbPath)!); + + _connectionString = new SqliteConnectionStringBuilder + { + DataSource = DbPath, + Mode = SqliteOpenMode.ReadWriteCreate, + Cache = SqliteCacheMode.Shared, + }.ToString(); + } + + public SqliteConnection Open() + { + var conn = new SqliteConnection(_connectionString); + conn.Open(); + + // WAL is a persistent DB-level setting; applying it once per process is enough, + // but idempotent so we do it defensively on the first connection we hand out. + if (Interlocked.Exchange(ref _walApplied, 1) == 0) + { + using var pragma = conn.CreateCommand(); + pragma.CommandText = "PRAGMA journal_mode=WAL;"; + pragma.ExecuteNonQuery(); + } + + using var fk = conn.CreateCommand(); + fk.CommandText = "PRAGMA foreign_keys=ON;"; + fk.ExecuteNonQuery(); + + return conn; + } +} diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj index 4cf11ce..ad75153 100644 --- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj +++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs new file mode 100644 index 0000000..6ef4b0d --- /dev/null +++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ClaudeDo.Data; + +namespace ClaudeDo.Worker.Config; + +public sealed class WorkerConfig +{ + [JsonPropertyName("db_path")] + public string DbPath { get; set; } = "~/.todo-app/todo.db"; + + [JsonPropertyName("sandbox_root")] + public string SandboxRoot { get; set; } = "~/.todo-app/sandbox"; + + [JsonPropertyName("log_root")] + public string LogRoot { get; set; } = "~/.todo-app/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; } = "~/.todo-app/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"; + + 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); + + return cfg; + } + + private static readonly JsonSerializerOptions JsonOpts = new() + { + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; +} diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs new file mode 100644 index 0000000..24d32ab --- /dev/null +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -0,0 +1,16 @@ +using System.Reflection; +using Microsoft.AspNetCore.SignalR; + +namespace ClaudeDo.Worker.Hub; + +/// +/// SignalR hub the UI connects to. Only is implemented at this stage; +/// RunNow/CancelTask/WakeQueue/GetActive land here once QueueService exists. +/// +public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub +{ + private static readonly string Version = + Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; + + public string Ping() => $"pong v{Version}"; +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 1760df1..55386fb 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -1,6 +1,27 @@ +using ClaudeDo.Data; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; + +var cfg = WorkerConfig.Load(); + var builder = WebApplication.CreateBuilder(args); + +// Initialize DB schema before the host starts accepting connections. +var dbFactory = new SqliteConnectionFactory(cfg.DbPath); +SchemaInitializer.Apply(dbFactory); + +builder.Services.AddSingleton(cfg); +builder.Services.AddSingleton(dbFactory); +builder.Services.AddSignalR(); + +// Loopback-only bind. Firewall is irrelevant for 127.0.0.1. +builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); + var app = builder.Build(); -app.MapGet("/", () => "Hello World!"); +app.MapHub("/hub"); + +app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})", + cfg.SignalRPort, cfg.DbPath); app.Run();