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:<port>, 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) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,11 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="..\..\schema\schema.sql" Link="schema.sql" LogicalName="ClaudeDo.Data.schema.sql" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
10
src/ClaudeDo.Data/Models/ListEntity.cs
Normal file
10
src/ClaudeDo.Data/Models/ListEntity.cs
Normal file
@@ -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";
|
||||||
|
}
|
||||||
7
src/ClaudeDo.Data/Models/TagEntity.cs
Normal file
7
src/ClaudeDo.Data/Models/TagEntity.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
public sealed class TagEntity
|
||||||
|
{
|
||||||
|
public long Id { get; init; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
}
|
||||||
26
src/ClaudeDo.Data/Models/TaskEntity.cs
Normal file
26
src/ClaudeDo.Data/Models/TaskEntity.cs
Normal file
@@ -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";
|
||||||
|
}
|
||||||
21
src/ClaudeDo.Data/Models/WorktreeEntity.cs
Normal file
21
src/ClaudeDo.Data/Models/WorktreeEntity.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
31
src/ClaudeDo.Data/Paths.cs
Normal file
31
src/ClaudeDo.Data/Paths.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
namespace ClaudeDo.Data;
|
||||||
|
|
||||||
|
public static class Paths
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Expands a leading "~" or "%USERPROFILE%" and returns an absolute path.
|
||||||
|
/// Relative paths are resolved against <paramref name="baseDir"/> (default: current directory).
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>~/.todo-app — parent directory for db, logs, config, sandbox, worktrees.</summary>
|
||||||
|
public static string AppDataRoot() =>
|
||||||
|
Expand("~/.todo-app");
|
||||||
|
}
|
||||||
41
src/ClaudeDo.Data/SchemaInitializer.cs
Normal file
41
src/ClaudeDo.Data/SchemaInitializer.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the embedded schema.sql script. Safe to call on every start — the script uses
|
||||||
|
/// IF NOT EXISTS / INSERT OR IGNORE.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/ClaudeDo.Data/SqliteConnectionFactory.cs
Normal file
48
src/ClaudeDo.Data/SqliteConnectionFactory.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens <see cref="SqliteConnection"/> instances pointed at <see cref="DbPath"/>.
|
||||||
|
/// First call ensures the parent directory exists, enables WAL and foreign keys.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|||||||
70
src/ClaudeDo.Worker/Config/WorkerConfig.cs
Normal file
70
src/ClaudeDo.Worker/Config/WorkerConfig.cs
Normal file
@@ -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";
|
||||||
|
|
||||||
|
/// <summary>"sibling" → place worktrees next to the target repo; "central" → under <see cref="CentralWorktreeRoot"/>.</summary>
|
||||||
|
[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");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the config from <paramref name="path"/> (defaults to <see cref="DefaultConfigPath"/>).
|
||||||
|
/// Missing file → returns defaults. Resolves all path-typed fields to absolute paths.
|
||||||
|
/// </summary>
|
||||||
|
public static WorkerConfig Load(string? path = null)
|
||||||
|
{
|
||||||
|
path ??= DefaultConfigPath;
|
||||||
|
|
||||||
|
WorkerConfig cfg;
|
||||||
|
if (File.Exists(path))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(path);
|
||||||
|
cfg = JsonSerializer.Deserialize<WorkerConfig>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/ClaudeDo.Worker/Hub/WorkerHub.cs
Normal file
16
src/ClaudeDo.Worker/Hub/WorkerHub.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Hub;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// SignalR hub the UI connects to. Only <see cref="Ping"/> is implemented at this stage;
|
||||||
|
/// RunNow/CancelTask/WakeQueue/GetActive land here once QueueService exists.
|
||||||
|
/// </summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
@@ -1,6 +1,27 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
|
||||||
|
var cfg = WorkerConfig.Load();
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.MapGet("/", () => "Hello World!");
|
app.MapHub<WorkerHub>("/hub");
|
||||||
|
|
||||||
|
app.Logger.LogInformation("ClaudeDo.Worker listening on http://127.0.0.1:{Port} (db: {Db})",
|
||||||
|
cfg.SignalRPort, cfg.DbPath);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
Reference in New Issue
Block a user