diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs
index 75741b4..329c32b 100644
--- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs
+++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs
@@ -87,6 +87,22 @@ public sealed class TaskRepository
.ToListAsync(ct);
}
+ ///
+ /// Returns all tasks that qualify as "real" Idle backlog items for online mirroring:
+ /// Status==Idle, no parent, PlanningPhase==None, not blocked.
+ ///
+ public async Task> GetAllIdleBacklogAsync(CancellationToken ct = default)
+ {
+ return await _context.Tasks
+ .AsNoTracking()
+ .Where(t => t.Status == TaskStatus.Idle
+ && t.ParentTaskId == null
+ && t.PlanningPhase == PlanningPhase.None
+ && t.BlockedByTaskId == null)
+ .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
+ .ToListAsync(ct);
+ }
+
#endregion
#region Status transitions
diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md
index cfaeaee..d7de8ad 100644
--- a/src/ClaudeDo.Worker/CLAUDE.md
+++ b/src/ClaudeDo.Worker/CLAUDE.md
@@ -19,6 +19,7 @@ Worker/
Hub/ — WorkerHub, HubBroadcaster
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
+ Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (stub — TODO(online-inbox) Phase 2), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
```
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
@@ -165,6 +166,12 @@ Loaded from `~/.todo-app/worker.config.json`:
- `queue_backstop_interval_ms` (default 30000)
- `signalr_port` (default 47821)
- `claude_bin` (path to claude CLI)
+- `online_inbox` — Online Inbox config (default: `enabled=false`, zero network when disabled):
+ - `enabled` (bool, default false) — when false the entire `Online/` stack is not registered
+ - `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled
+ - `poll_interval_seconds` (int, default 60)
+ - `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` (Phase 2; not used until ZitadelAuthProvider is wired)
+ - The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
diff --git a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
index aac05c3..dad1691 100644
--- a/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+++ b/src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
@@ -14,6 +14,7 @@
+
diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
index 7f35b24..17ca294 100644
--- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs
+++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
@@ -1,6 +1,7 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ClaudeDo.Data;
+using ClaudeDo.Worker.Online;
namespace ClaudeDo.Worker.Config;
@@ -39,6 +40,9 @@ public sealed class WorkerConfig
[JsonPropertyName("external_mcp_api_key")]
public string? ExternalMcpApiKey { get; set; }
+ [JsonPropertyName("online_inbox")]
+ public OnlineInboxConfig OnlineInbox { get; set; } = new();
+
public static string DefaultConfigPath =>
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
diff --git a/src/ClaudeDo.Worker/Online/Dtos.cs b/src/ClaudeDo.Worker/Online/Dtos.cs
new file mode 100644
index 0000000..8eccb61
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/Dtos.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+
+namespace ClaudeDo.Worker.Online;
+
+public sealed record RemoteList(
+ [property: JsonPropertyName("id")] string Id,
+ [property: JsonPropertyName("name")] string Name);
+
+public sealed record RemoteTask(
+ [property: JsonPropertyName("id")] string Id,
+ [property: JsonPropertyName("listId")] string ListId,
+ [property: JsonPropertyName("title")] string Title,
+ [property: JsonPropertyName("description")] string? Description,
+ [property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
+
+public sealed record MirrorTask(
+ [property: JsonPropertyName("id")] string Id,
+ [property: JsonPropertyName("listId")] string ListId,
+ [property: JsonPropertyName("title")] string Title,
+ [property: JsonPropertyName("description")] string? Description);
diff --git a/src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs b/src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs
new file mode 100644
index 0000000..ee9b208
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs
@@ -0,0 +1,9 @@
+namespace ClaudeDo.Worker.Online;
+
+public interface IOnlineInboxApi
+{
+ Task PutListsAsync(IReadOnlyList lists, CancellationToken ct = default);
+ Task> GetUnimportedTasksAsync(CancellationToken ct = default);
+ Task MarkImportedAsync(string id, CancellationToken ct = default);
+ Task PutMirrorAsync(IReadOnlyList tasks, CancellationToken ct = default);
+}
diff --git a/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs b/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs
new file mode 100644
index 0000000..3e250cb
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs
@@ -0,0 +1,6 @@
+namespace ClaudeDo.Worker.Online.Interfaces;
+
+public interface IOnlineAuthProvider
+{
+ Task GetAccessTokenAsync(CancellationToken ct = default);
+}
diff --git a/src/ClaudeDo.Worker/Online/OnlineBacklog.cs b/src/ClaudeDo.Worker/Online/OnlineBacklog.cs
new file mode 100644
index 0000000..3021298
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/OnlineBacklog.cs
@@ -0,0 +1,27 @@
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using Microsoft.EntityFrameworkCore;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Online;
+
+public static class OnlineBacklog
+{
+ ///
+ /// Returns the current Idle backlog: Status==Idle, no parent, PlanningPhase==None, not blocked.
+ /// These are the tasks mirrored to the online store (§2 of the contract).
+ ///
+ public static async Task> CurrentAsync(
+ TaskRepository tasks,
+ CancellationToken ct = default)
+ {
+ var all = await tasks.GetAllIdleBacklogAsync(ct);
+ return all.Select(t => new MirrorTask(t.Id, t.ListId, t.Title, t.Description)).ToList();
+ }
+
+ internal static bool IsBacklogItem(TaskEntity t) =>
+ t.Status == TaskStatus.Idle
+ && t.ParentTaskId == null
+ && t.PlanningPhase == PlanningPhase.None
+ && t.BlockedByTaskId == null;
+}
diff --git a/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs b/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs
new file mode 100644
index 0000000..a5a8377
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs
@@ -0,0 +1,84 @@
+using System.Net.Http.Json;
+using ClaudeDo.Worker.Online.Interfaces;
+
+namespace ClaudeDo.Worker.Online;
+
+public sealed class OnlineInboxApiClient : IOnlineInboxApi
+{
+ private readonly HttpClient _http;
+ private readonly IOnlineAuthProvider _auth;
+
+ public OnlineInboxApiClient(HttpClient http, IOnlineAuthProvider auth)
+ {
+ _http = http;
+ _auth = auth;
+ }
+
+ ///
+ /// Validates that is HTTPS or a loopback address.
+ /// Throws for non-HTTPS non-loopback URLs.
+ ///
+ public static void ValidateBaseUrl(string baseUrl)
+ {
+ if (string.IsNullOrWhiteSpace(baseUrl))
+ throw new InvalidOperationException("online_inbox.api_base_url is not configured.");
+
+ var uri = new Uri(baseUrl, UriKind.Absolute);
+ if (uri.Scheme != "https" && !uri.IsLoopback)
+ throw new InvalidOperationException(
+ $"online_inbox.api_base_url must be HTTPS or loopback. Got: {baseUrl}");
+ }
+
+ public async Task PutListsAsync(IReadOnlyList lists, CancellationToken ct = default)
+ {
+ using var req = await BuildAsync(HttpMethod.Put, "/lists", lists, ct);
+ using var resp = await _http.SendAsync(req, ct);
+ await EnsureSuccessAsync(resp, ct);
+ }
+
+ public async Task> GetUnimportedTasksAsync(CancellationToken ct = default)
+ {
+ using var req = await BuildAsync(HttpMethod.Get, "/tasks?imported=false", null, ct);
+ using var resp = await _http.SendAsync(req, ct);
+ await EnsureSuccessAsync(resp, ct);
+ var result = await resp.Content.ReadFromJsonAsync>(ct);
+ return result ?? [];
+ }
+
+ public async Task MarkImportedAsync(string id, CancellationToken ct = default)
+ {
+ using var req = await BuildAsync(HttpMethod.Post, $"/tasks/{Uri.EscapeDataString(id)}/imported", null, ct);
+ using var resp = await _http.SendAsync(req, ct);
+ await EnsureSuccessAsync(resp, ct);
+ }
+
+ public async Task PutMirrorAsync(IReadOnlyList tasks, CancellationToken ct = default)
+ {
+ using var req = await BuildAsync(HttpMethod.Put, "/tasks/mirror", tasks, ct);
+ using var resp = await _http.SendAsync(req, ct);
+ await EnsureSuccessAsync(resp, ct);
+ }
+
+ private async Task BuildAsync(
+ HttpMethod method,
+ string path,
+ object? body,
+ CancellationToken ct)
+ {
+ var token = await _auth.GetAccessTokenAsync(ct);
+ var req = new HttpRequestMessage(method, path);
+ if (token is not null)
+ req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
+ if (body is not null)
+ req.Content = JsonContent.Create(body);
+ return req;
+ }
+
+ private static async Task EnsureSuccessAsync(HttpResponseMessage resp, CancellationToken ct)
+ {
+ if (resp.IsSuccessStatusCode) return;
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ throw new OnlineInboxException((int)resp.StatusCode,
+ $"Online Inbox API error {(int)resp.StatusCode}: {body}");
+ }
+}
diff --git a/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs b/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs
new file mode 100644
index 0000000..5dd3ccd
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace ClaudeDo.Worker.Online;
+
+public sealed class ZitadelClientConfig
+{
+ [JsonPropertyName("authority")]
+ public string Authority { get; set; } = "";
+
+ [JsonPropertyName("client_id")]
+ public string ClientId { get; set; } = "";
+
+ [JsonPropertyName("scopes")]
+ public string Scopes { get; set; } = "openid offline_access";
+}
+
+public sealed class OnlineInboxConfig
+{
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; } = false;
+
+ [JsonPropertyName("api_base_url")]
+ public string ApiBaseUrl { get; set; } = "";
+
+ [JsonPropertyName("poll_interval_seconds")]
+ public int PollIntervalSeconds { get; set; } = 60;
+
+ [JsonPropertyName("zitadel")]
+ public ZitadelClientConfig Zitadel { get; set; } = new();
+}
diff --git a/src/ClaudeDo.Worker/Online/OnlineInboxException.cs b/src/ClaudeDo.Worker/Online/OnlineInboxException.cs
new file mode 100644
index 0000000..142e1f5
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/OnlineInboxException.cs
@@ -0,0 +1,12 @@
+namespace ClaudeDo.Worker.Online;
+
+public sealed class OnlineInboxException : Exception
+{
+ public int StatusCode { get; }
+
+ public OnlineInboxException(int statusCode, string message)
+ : base(message)
+ {
+ StatusCode = statusCode;
+ }
+}
diff --git a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs
new file mode 100644
index 0000000..92ac8e6
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs
@@ -0,0 +1,121 @@
+using ClaudeDo.Data;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Online.Interfaces;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Hosting;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Online;
+
+public sealed class OnlineSyncService : BackgroundService
+{
+ private readonly IDbContextFactory _dbFactory;
+ private readonly IOnlineInboxApi _api;
+ private readonly IOnlineAuthProvider _auth;
+ private readonly OnlineInboxConfig _config;
+ private readonly ILogger _logger;
+
+ public OnlineSyncService(
+ IDbContextFactory dbFactory,
+ IOnlineInboxApi api,
+ IOnlineAuthProvider auth,
+ OnlineInboxConfig config,
+ ILogger logger)
+ {
+ _dbFactory = dbFactory;
+ _api = api;
+ _auth = auth;
+ _config = config;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ try
+ {
+ await TickAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "OnlineSyncService cycle failed; backing off to next interval");
+ }
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(_config.PollIntervalSeconds), stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ }
+ }
+
+ internal async Task TickAsync(CancellationToken ct)
+ {
+ var token = await _auth.GetAccessTokenAsync(ct);
+ if (token is null)
+ {
+ _logger.LogDebug("OnlineSyncService: no access token, skipping cycle");
+ return;
+ }
+
+ await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
+ var tasks = new TaskRepository(ctx);
+ var lists = new ListRepository(ctx);
+
+ // Step 1: pull unimported tasks, import them locally, mark each imported.
+ var unimported = await _api.GetUnimportedTasksAsync(ct);
+ foreach (var remote in unimported)
+ {
+ var existing = await tasks.GetByIdAsync(remote.Id, ct);
+ if (existing is not null)
+ {
+ // Already imported locally; just mark it on the server.
+ await _api.MarkImportedAsync(remote.Id, ct);
+ continue;
+ }
+
+ var list = await lists.GetByIdAsync(remote.ListId, ct);
+ if (list is null)
+ {
+ _logger.LogWarning(
+ "OnlineSyncService: remote task {Id} references unknown list {ListId}; skipping",
+ remote.Id, remote.ListId);
+ continue;
+ }
+
+ var entity = new TaskEntity
+ {
+ Id = remote.Id,
+ ListId = remote.ListId,
+ Title = remote.Title,
+ Description = remote.Description,
+ Status = TaskStatus.Idle,
+ CreatedBy = "online",
+ CreatedAt = remote.CreatedAt.UtcDateTime,
+ CommitType = CommitTypeRegistry.DefaultType,
+ };
+ await tasks.AddAsync(entity, ct);
+ await _api.MarkImportedAsync(remote.Id, ct);
+
+ _logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
+ }
+
+ // Step 2: push full list catalog.
+ var allLists = await lists.GetAllAsync(ct);
+ var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name)).ToList();
+ await _api.PutListsAsync(remoteLists, ct);
+
+ // Step 3: push current Idle backlog mirror.
+ var mirror = await OnlineBacklog.CurrentAsync(tasks, ct);
+ await _api.PutMirrorAsync(mirror, ct);
+ }
+}
diff --git a/src/ClaudeDo.Worker/Online/OnlineTokenStore.cs b/src/ClaudeDo.Worker/Online/OnlineTokenStore.cs
new file mode 100644
index 0000000..29a7510
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/OnlineTokenStore.cs
@@ -0,0 +1,54 @@
+using System.Runtime.Versioning;
+using System.Security.Cryptography;
+using System.Text;
+using ClaudeDo.Data;
+
+namespace ClaudeDo.Worker.Online;
+
+///
+/// Persists the Zitadel refresh token encrypted with DPAPI (CurrentUser scope).
+/// Windows-only; the file lives at ~/.todo-app/online-inbox.token.
+///
+[SupportedOSPlatform("windows")]
+public sealed class OnlineTokenStore
+{
+ private readonly string _tokenPath;
+
+ public OnlineTokenStore()
+ : this(Path.Combine(Paths.AppDataRoot(), "online-inbox.token")) { }
+
+ internal OnlineTokenStore(string tokenPath)
+ {
+ _tokenPath = tokenPath;
+ }
+
+ public void Save(string refreshToken)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(refreshToken);
+ var plain = Encoding.UTF8.GetBytes(refreshToken);
+ var cipher = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
+ Directory.CreateDirectory(Path.GetDirectoryName(_tokenPath)!);
+ File.WriteAllBytes(_tokenPath, cipher);
+ }
+
+ public string? Read()
+ {
+ if (!File.Exists(_tokenPath)) return null;
+ try
+ {
+ var cipher = File.ReadAllBytes(_tokenPath);
+ var plain = ProtectedData.Unprotect(cipher, null, DataProtectionScope.CurrentUser);
+ return Encoding.UTF8.GetString(plain);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public void Clear()
+ {
+ if (File.Exists(_tokenPath))
+ File.Delete(_tokenPath);
+ }
+}
diff --git a/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs b/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs
new file mode 100644
index 0000000..bbb7906
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs
@@ -0,0 +1,21 @@
+using ClaudeDo.Worker.Online.Interfaces;
+
+namespace ClaudeDo.Worker.Online;
+
+///
+/// Simple that returns a fixed token supplied at construction.
+/// Used as the default DI registration until ZitadelAuthProvider is wired (Phase 2).
+/// Also serves as the test double.
+///
+public sealed class StaticTokenAuthProvider : IOnlineAuthProvider
+{
+ private readonly string? _token;
+
+ public StaticTokenAuthProvider(string? token = null)
+ {
+ _token = token;
+ }
+
+ public Task GetAccessTokenAsync(CancellationToken ct = default)
+ => Task.FromResult(_token);
+}
diff --git a/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs
new file mode 100644
index 0000000..4587b4d
--- /dev/null
+++ b/src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs
@@ -0,0 +1,13 @@
+using ClaudeDo.Worker.Online.Interfaces;
+
+namespace ClaudeDo.Worker.Online;
+
+// TODO(online-inbox): wire the Zitadel package once client config is known (Phase 2).
+// Replace this stub with a real implementation that uses OnlineTokenStore to read the
+// refresh token and exchanges it for an access token via the Zitadel OIDC endpoint,
+// caching the access token until near expiry.
+public sealed class ZitadelAuthProvider : IOnlineAuthProvider
+{
+ public Task GetAccessTokenAsync(CancellationToken ct = default)
+ => Task.FromResult(null);
+}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index 7348efe..2b61241 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -11,6 +11,8 @@ using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
+using ClaudeDo.Worker.Online;
+using ClaudeDo.Worker.Online.Interfaces;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report;
@@ -149,6 +151,22 @@ builder.Services.AddMcpServer()
.WithTools()
.WithTools();
+// Online Inbox — registered only when enabled.
+if (cfg.OnlineInbox.Enabled)
+{
+ OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl);
+ builder.Services.AddSingleton(cfg.OnlineInbox);
+ builder.Services.AddSingleton();
+#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
+ builder.Services.AddSingleton();
+#pragma warning restore CA1416
+ builder.Services.AddHttpClient(client =>
+ {
+ client.BaseAddress = new Uri(cfg.OnlineInbox.ApiBaseUrl.TrimEnd('/') + "/");
+ });
+ builder.Services.AddHostedService();
+}
+
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineBacklogTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineBacklogTests.cs
new file mode 100644
index 0000000..6faf731
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineBacklogTests.cs
@@ -0,0 +1,167 @@
+using ClaudeDo.Data;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Online;
+using ClaudeDo.Worker.Tests.Infrastructure;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Tests.Online;
+
+public sealed class OnlineBacklogTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+ private readonly ClaudeDoDbContext _ctx;
+ private readonly TaskRepository _tasks;
+ private readonly ListRepository _lists;
+ private string _listId = null!;
+
+ public OnlineBacklogTests()
+ {
+ _ctx = _db.CreateContext();
+ _tasks = new TaskRepository(_ctx);
+ _lists = new ListRepository(_ctx);
+ }
+
+ public void Dispose()
+ {
+ _ctx.Dispose();
+ _db.Dispose();
+ }
+
+ private async Task SeedListAsync()
+ {
+ _listId = Guid.NewGuid().ToString();
+ await _lists.AddAsync(new ListEntity { Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow });
+ }
+
+ private TaskEntity Make(
+ TaskStatus status = TaskStatus.Idle,
+ string? parentId = null,
+ PlanningPhase planning = PlanningPhase.None,
+ string? blockedById = null) => new()
+ {
+ Id = Guid.NewGuid().ToString(),
+ ListId = _listId,
+ Title = "T",
+ Status = status,
+ ParentTaskId = parentId,
+ PlanningPhase = planning,
+ BlockedByTaskId = blockedById,
+ CreatedAt = DateTime.UtcNow,
+ };
+
+ [Fact]
+ public async Task CurrentAsync_Returns_OnlyIdleBacklogItems()
+ {
+ await SeedListAsync();
+
+ var idle = Make(TaskStatus.Idle);
+ var queued = Make(TaskStatus.Queued);
+ var running = Make(TaskStatus.Running);
+ var done = Make(TaskStatus.Done);
+ var failed = Make(TaskStatus.Failed);
+
+ // Idle but with parent → planning child
+ var parent = Make(TaskStatus.Idle);
+ await _tasks.AddAsync(parent);
+ var child = Make(TaskStatus.Idle, parentId: parent.Id);
+
+ // Idle but with PlanningPhase
+ var planningParent = Make(TaskStatus.Idle, planning: PlanningPhase.Active);
+
+ // Idle but blocked
+ await _tasks.AddAsync(idle);
+ var blocker = Make(TaskStatus.Idle);
+ await _tasks.AddAsync(blocker);
+ var blocked = Make(TaskStatus.Idle, blockedById: blocker.Id);
+
+ await _tasks.AddAsync(queued);
+ await _tasks.AddAsync(running);
+ await _tasks.AddAsync(done);
+ await _tasks.AddAsync(failed);
+ await _tasks.AddAsync(child);
+ await _tasks.AddAsync(planningParent);
+ await _tasks.AddAsync(blocked);
+
+ var mirror = await OnlineBacklog.CurrentAsync(_tasks);
+
+ // Only the plain idle task and the blocker (which itself is plain idle) should appear
+ var ids = mirror.Select(m => m.Id).ToHashSet();
+ Assert.Contains(idle.Id, ids);
+ Assert.Contains(blocker.Id, ids);
+ Assert.Contains(parent.Id, ids); // parent with no parent itself is a backlog item
+
+ Assert.DoesNotContain(queued.Id, ids);
+ Assert.DoesNotContain(running.Id, ids);
+ Assert.DoesNotContain(done.Id, ids);
+ Assert.DoesNotContain(failed.Id, ids);
+ Assert.DoesNotContain(child.Id, ids);
+ Assert.DoesNotContain(planningParent.Id, ids);
+ Assert.DoesNotContain(blocked.Id, ids);
+ }
+
+ [Fact]
+ public void IsBacklogItem_Predicate_FiltersCorrectly()
+ {
+ Assert.True(OnlineBacklog.IsBacklogItem(new TaskEntity
+ {
+ Id = "1", ListId = "l", Title = "T",
+ Status = TaskStatus.Idle, ParentTaskId = null,
+ PlanningPhase = PlanningPhase.None, BlockedByTaskId = null,
+ CreatedAt = DateTime.UtcNow,
+ }));
+ Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity
+ {
+ Id = "2", ListId = "l", Title = "T",
+ Status = TaskStatus.Queued, ParentTaskId = null,
+ PlanningPhase = PlanningPhase.None, BlockedByTaskId = null,
+ CreatedAt = DateTime.UtcNow,
+ }));
+ Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity
+ {
+ Id = "3", ListId = "l", Title = "T",
+ Status = TaskStatus.Idle, ParentTaskId = "p",
+ PlanningPhase = PlanningPhase.None, BlockedByTaskId = null,
+ CreatedAt = DateTime.UtcNow,
+ }));
+ Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity
+ {
+ Id = "4", ListId = "l", Title = "T",
+ Status = TaskStatus.Idle, ParentTaskId = null,
+ PlanningPhase = PlanningPhase.Active, BlockedByTaskId = null,
+ CreatedAt = DateTime.UtcNow,
+ }));
+ Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity
+ {
+ Id = "5", ListId = "l", Title = "T",
+ Status = TaskStatus.Idle, ParentTaskId = null,
+ PlanningPhase = PlanningPhase.None, BlockedByTaskId = "b",
+ CreatedAt = DateTime.UtcNow,
+ }));
+ }
+
+ [Fact]
+ public async Task CurrentAsync_EmptyDb_ReturnsEmpty()
+ {
+ await SeedListAsync();
+ var mirror = await OnlineBacklog.CurrentAsync(_tasks);
+ Assert.Empty(mirror);
+ }
+
+ [Fact]
+ public async Task CurrentAsync_MapsFieldsCorrectly()
+ {
+ await SeedListAsync();
+ var task = Make(TaskStatus.Idle);
+ task.Description = "desc";
+ await _tasks.AddAsync(task);
+
+ var mirror = await OnlineBacklog.CurrentAsync(_tasks);
+
+ Assert.Single(mirror);
+ Assert.Equal(task.Id, mirror[0].Id);
+ Assert.Equal(_listId, mirror[0].ListId);
+ Assert.Equal("T", mirror[0].Title);
+ Assert.Equal("desc", mirror[0].Description);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs
new file mode 100644
index 0000000..0a87891
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs
@@ -0,0 +1,183 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using ClaudeDo.Worker.Online;
+
+namespace ClaudeDo.Worker.Tests.Online;
+
+///
+/// Tests for using a stubbed .
+///
+public sealed class OnlineInboxApiClientTests
+{
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ public List Requests { get; } = new();
+ public HttpStatusCode ResponseStatus { get; set; } = HttpStatusCode.OK;
+ public string ResponseBody { get; set; } = "[]";
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ Requests.Add(request);
+ var resp = new HttpResponseMessage(ResponseStatus)
+ {
+ Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
+ };
+ return Task.FromResult(resp);
+ }
+ }
+
+ private static (OnlineInboxApiClient Client, StubHandler Handler) Build(string? token = "test-token")
+ {
+ var handler = new StubHandler();
+ var http = new HttpClient(handler) { BaseAddress = new Uri("https://inbox.example.com/") };
+ var auth = new StaticTokenAuthProvider(token);
+ return (new OnlineInboxApiClient(http, auth), handler);
+ }
+
+ // ---- PutListsAsync ----
+
+ [Fact]
+ public async Task PutListsAsync_UsesCorrectVerbAndPath()
+ {
+ var (client, handler) = Build();
+ await client.PutListsAsync([new RemoteList("id1", "List 1")]);
+
+ Assert.Single(handler.Requests);
+ Assert.Equal(HttpMethod.Put, handler.Requests[0].Method);
+ Assert.Equal("/lists", handler.Requests[0].RequestUri!.AbsolutePath);
+ }
+
+ [Fact]
+ public async Task PutListsAsync_AttachesBearerToken()
+ {
+ var (client, handler) = Build("my-bearer");
+ await client.PutListsAsync([]);
+
+ Assert.Equal("Bearer", handler.Requests[0].Headers.Authorization!.Scheme);
+ Assert.Equal("my-bearer", handler.Requests[0].Headers.Authorization!.Parameter);
+ }
+
+ // ---- GetUnimportedTasksAsync ----
+
+ [Fact]
+ public async Task GetUnimportedTasksAsync_UsesGetWithQueryParam()
+ {
+ var (client, handler) = Build();
+ handler.ResponseBody = "[]";
+ await client.GetUnimportedTasksAsync();
+
+ Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
+ Assert.Contains("imported=false", handler.Requests[0].RequestUri!.Query);
+ }
+
+ [Fact]
+ public async Task GetUnimportedTasksAsync_DeserializesResponse()
+ {
+ var (client, handler) = Build();
+ var tasks = new[]
+ {
+ new { id = "t1", listId = "l1", title = "Title", description = (string?)null, createdAt = DateTimeOffset.UtcNow },
+ };
+ handler.ResponseBody = JsonSerializer.Serialize(tasks);
+
+ var result = await client.GetUnimportedTasksAsync();
+
+ Assert.Single(result);
+ Assert.Equal("t1", result[0].Id);
+ Assert.Equal("l1", result[0].ListId);
+ Assert.Equal("Title", result[0].Title);
+ }
+
+ // ---- MarkImportedAsync ----
+
+ [Fact]
+ public async Task MarkImportedAsync_UsesPostAndCorrectPath()
+ {
+ var (client, handler) = Build();
+ await client.MarkImportedAsync("task-id-123");
+
+ Assert.Equal(HttpMethod.Post, handler.Requests[0].Method);
+ Assert.Equal("/tasks/task-id-123/imported", handler.Requests[0].RequestUri!.AbsolutePath);
+ }
+
+ // ---- PutMirrorAsync ----
+
+ [Fact]
+ public async Task PutMirrorAsync_UsesPutAndCorrectPath()
+ {
+ var (client, handler) = Build();
+ await client.PutMirrorAsync([new MirrorTask("id1", "l1", "T", null)]);
+
+ Assert.Equal(HttpMethod.Put, handler.Requests[0].Method);
+ Assert.Equal("/tasks/mirror", handler.Requests[0].RequestUri!.AbsolutePath);
+ }
+
+ // ---- 401 handling ----
+
+ [Fact]
+ public async Task NonSuccessResponse_Throws_OnlineInboxException()
+ {
+ var (client, handler) = Build();
+ handler.ResponseStatus = HttpStatusCode.Unauthorized;
+ handler.ResponseBody = "Unauthorized";
+
+ var ex = await Assert.ThrowsAsync(
+ () => client.PutListsAsync([]));
+
+ Assert.Equal(401, ex.StatusCode);
+ }
+
+ [Fact]
+ public async Task ServerError_Throws_OnlineInboxException_WithStatusCode()
+ {
+ var (client, handler) = Build();
+ handler.ResponseStatus = HttpStatusCode.InternalServerError;
+ handler.ResponseBody = "error";
+
+ var ex = await Assert.ThrowsAsync(
+ () => client.GetUnimportedTasksAsync());
+
+ Assert.Equal(500, ex.StatusCode);
+ }
+
+ // ---- No token ----
+
+ [Fact]
+ public async Task NoToken_SendsRequestWithoutAuthHeader()
+ {
+ var (client, handler) = Build(token: null);
+ await client.PutListsAsync([]);
+
+ Assert.Null(handler.Requests[0].Headers.Authorization);
+ }
+
+ // ---- URL validation ----
+
+ [Fact]
+ public void ValidateBaseUrl_AcceptsHttps()
+ {
+ OnlineInboxApiClient.ValidateBaseUrl("https://example.com");
+ }
+
+ [Fact]
+ public void ValidateBaseUrl_AcceptsLoopback()
+ {
+ OnlineInboxApiClient.ValidateBaseUrl("http://127.0.0.1:5000");
+ OnlineInboxApiClient.ValidateBaseUrl("http://localhost:5000");
+ }
+
+ [Fact]
+ public void ValidateBaseUrl_Rejects_HttpNonLoopback()
+ {
+ Assert.Throws(
+ () => OnlineInboxApiClient.ValidateBaseUrl("http://example.com"));
+ }
+
+ [Fact]
+ public void ValidateBaseUrl_Rejects_Empty()
+ {
+ Assert.Throws(
+ () => OnlineInboxApiClient.ValidateBaseUrl(""));
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxConfigTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxConfigTests.cs
new file mode 100644
index 0000000..b99e262
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineInboxConfigTests.cs
@@ -0,0 +1,78 @@
+using System.Text.Json;
+using ClaudeDo.Worker.Config;
+using ClaudeDo.Worker.Online;
+
+namespace ClaudeDo.Worker.Tests.Online;
+
+public sealed class OnlineInboxConfigTests : IDisposable
+{
+ private readonly string _configPath = Path.Combine(Path.GetTempPath(), $"worker_cfg_{Guid.NewGuid():N}.json");
+
+ public void Dispose()
+ {
+ try { File.Delete(_configPath); } catch { }
+ }
+
+ [Fact]
+ public void MissingSection_Returns_DisabledDefaults()
+ {
+ File.WriteAllText(_configPath, "{}");
+ var cfg = WorkerConfig.Load(_configPath);
+
+ Assert.False(cfg.OnlineInbox.Enabled);
+ Assert.Equal("", cfg.OnlineInbox.ApiBaseUrl);
+ Assert.Equal(60, cfg.OnlineInbox.PollIntervalSeconds);
+ Assert.Equal("", cfg.OnlineInbox.Zitadel.Authority);
+ Assert.Equal("", cfg.OnlineInbox.Zitadel.ClientId);
+ Assert.Equal("openid offline_access", cfg.OnlineInbox.Zitadel.Scopes);
+ }
+
+ [Fact]
+ public void MissingFile_Returns_DisabledDefaults()
+ {
+ var cfg = WorkerConfig.Load(Path.Combine(Path.GetTempPath(), $"nonexistent_{Guid.NewGuid():N}.json"));
+
+ Assert.False(cfg.OnlineInbox.Enabled);
+ Assert.Equal(60, cfg.OnlineInbox.PollIntervalSeconds);
+ }
+
+ [Fact]
+ public void PopulatedSection_RoundTrips()
+ {
+ var json = """
+ {
+ "online_inbox": {
+ "enabled": true,
+ "api_base_url": "https://inbox.claudedo.kuns.dev",
+ "poll_interval_seconds": 120,
+ "zitadel": {
+ "authority": "https://auth.example.com",
+ "client_id": "abc123",
+ "scopes": "openid offline_access profile"
+ }
+ }
+ }
+ """;
+ File.WriteAllText(_configPath, json);
+ var cfg = WorkerConfig.Load(_configPath);
+
+ Assert.True(cfg.OnlineInbox.Enabled);
+ Assert.Equal("https://inbox.claudedo.kuns.dev", cfg.OnlineInbox.ApiBaseUrl);
+ Assert.Equal(120, cfg.OnlineInbox.PollIntervalSeconds);
+ Assert.Equal("https://auth.example.com", cfg.OnlineInbox.Zitadel.Authority);
+ Assert.Equal("abc123", cfg.OnlineInbox.Zitadel.ClientId);
+ Assert.Equal("openid offline_access profile", cfg.OnlineInbox.Zitadel.Scopes);
+ }
+
+ [Fact]
+ public void PartialSection_UsesDefaultsForMissingFields()
+ {
+ var json = """{"online_inbox": {"enabled": true}}""";
+ File.WriteAllText(_configPath, json);
+ var cfg = WorkerConfig.Load(_configPath);
+
+ Assert.True(cfg.OnlineInbox.Enabled);
+ Assert.Equal("", cfg.OnlineInbox.ApiBaseUrl);
+ Assert.Equal(60, cfg.OnlineInbox.PollIntervalSeconds);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs
new file mode 100644
index 0000000..a5c2535
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs
@@ -0,0 +1,242 @@
+using ClaudeDo.Data;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Online;
+using ClaudeDo.Worker.Tests.Infrastructure;
+using Microsoft.Extensions.Logging.Abstractions;
+using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
+
+namespace ClaudeDo.Worker.Tests.Online;
+
+///
+/// Integration tests for using a fake API + real SQLite.
+///
+public sealed class OnlineSyncServiceTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+
+ public void Dispose() => _db.Dispose();
+
+ // ---- fake API ----
+
+ private sealed class FakeApi : IOnlineInboxApi
+ {
+ public List UnimportedTasks { get; set; } = [];
+ public List ReceivedLists { get; } = [];
+ public List ReceivedMirror { get; } = [];
+ public List MarkedImported { get; } = [];
+ public int CallCount { get; private set; }
+
+ public Task PutListsAsync(IReadOnlyList lists, CancellationToken ct = default)
+ {
+ CallCount++;
+ ReceivedLists.AddRange(lists);
+ return Task.CompletedTask;
+ }
+
+ public Task> GetUnimportedTasksAsync(CancellationToken ct = default)
+ {
+ CallCount++;
+ return Task.FromResult>(UnimportedTasks);
+ }
+
+ public Task MarkImportedAsync(string id, CancellationToken ct = default)
+ {
+ CallCount++;
+ MarkedImported.Add(id);
+ return Task.CompletedTask;
+ }
+
+ public Task PutMirrorAsync(IReadOnlyList tasks, CancellationToken ct = default)
+ {
+ CallCount++;
+ ReceivedMirror.AddRange(tasks);
+ return Task.CompletedTask;
+ }
+ }
+
+ private OnlineSyncService BuildService(FakeApi api, string? token = "test-token")
+ {
+ var config = new OnlineInboxConfig { Enabled = true, PollIntervalSeconds = 60 };
+ var auth = new StaticTokenAuthProvider(token);
+ return new OnlineSyncService(
+ _db.CreateFactory(),
+ api,
+ auth,
+ config,
+ NullLogger.Instance);
+ }
+
+ private async Task<(string ListId, ClaudeDoDbContext Ctx, TaskRepository Tasks, ListRepository Lists)> SeedAsync()
+ {
+ var ctx = _db.CreateContext();
+ var lists = new ListRepository(ctx);
+ var tasks = new TaskRepository(ctx);
+ var listId = Guid.NewGuid().ToString();
+ await lists.AddAsync(new ListEntity { Id = listId, Name = "MyList", CreatedAt = DateTime.UtcNow });
+ return (listId, ctx, tasks, lists);
+ }
+
+ // ---- pull → import → flag ----
+
+ [Fact]
+ public async Task Tick_Imports_RemoteTask_And_MarksImported()
+ {
+ var (listId, ctx, tasks, _) = await SeedAsync();
+ using var _ = ctx;
+
+ var remoteId = Guid.NewGuid().ToString();
+ var api = new FakeApi
+ {
+ UnimportedTasks = [new RemoteTask(remoteId, listId, "From Web", "desc", DateTimeOffset.UtcNow)],
+ };
+ var svc = BuildService(api);
+
+ await svc.TickAsync(CancellationToken.None);
+
+ var imported = await tasks.GetByIdAsync(remoteId);
+ Assert.NotNull(imported);
+ Assert.Equal("From Web", imported!.Title);
+ Assert.Equal("desc", imported.Description);
+ Assert.Equal(TaskStatus.Idle, imported.Status);
+ Assert.Equal("online", imported.CreatedBy);
+ Assert.Contains(remoteId, api.MarkedImported);
+ }
+
+ [Fact]
+ public async Task Tick_UnknownList_Skips_And_DoesNotMark()
+ {
+ var _ = await SeedAsync();
+ var remoteId = Guid.NewGuid().ToString();
+ var api = new FakeApi
+ {
+ UnimportedTasks = [new RemoteTask(remoteId, "unknown-list-id", "T", null, DateTimeOffset.UtcNow)],
+ };
+ var svc = BuildService(api);
+
+ await svc.TickAsync(CancellationToken.None);
+
+ using var ctx = _db.CreateContext();
+ var tasks = new TaskRepository(ctx);
+ Assert.Null(await tasks.GetByIdAsync(remoteId));
+ Assert.DoesNotContain(remoteId, api.MarkedImported);
+ }
+
+ // ---- mirror == Idle backlog ----
+
+ [Fact]
+ public async Task Tick_Mirror_Contains_Idle_Backlog()
+ {
+ var (listId, ctx, tasks, _) = await SeedAsync();
+ using var _ = ctx;
+
+ // Idle task → should appear in mirror
+ var idle = new TaskEntity
+ {
+ Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Idle Task",
+ Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
+ };
+ await tasks.AddAsync(idle);
+
+ // Queued task → must NOT appear
+ var queued = new TaskEntity
+ {
+ Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Queued",
+ Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow,
+ };
+ await tasks.AddAsync(queued);
+
+ var api = new FakeApi();
+ var svc = BuildService(api);
+ await svc.TickAsync(CancellationToken.None);
+
+ var mirrorIds = api.ReceivedMirror.Select(m => m.Id).ToHashSet();
+ Assert.Contains(idle.Id, mirrorIds);
+ Assert.DoesNotContain(queued.Id, mirrorIds);
+ }
+
+ [Fact]
+ public async Task Tick_ImportedTask_IncludedInMirror()
+ {
+ // Newly imported tasks must be part of the mirror sent in the same cycle (order matters).
+ var (listId, ctx, tasks, _) = await SeedAsync();
+ using var _ = ctx;
+
+ var remoteId = Guid.NewGuid().ToString();
+ var api = new FakeApi
+ {
+ UnimportedTasks = [new RemoteTask(remoteId, listId, "New", null, DateTimeOffset.UtcNow)],
+ };
+ var svc = BuildService(api);
+ await svc.TickAsync(CancellationToken.None);
+
+ // Imported task lands Idle → must be in the mirror payload
+ Assert.Contains(api.ReceivedMirror, m => m.Id == remoteId);
+ }
+
+ // ---- lists pushed ----
+
+ [Fact]
+ public async Task Tick_Pushes_AllLists()
+ {
+ var (listId, ctx, _, lists) = await SeedAsync();
+ using var _ = ctx;
+
+ // Add a second list
+ var listId2 = Guid.NewGuid().ToString();
+ await lists.AddAsync(new ListEntity { Id = listId2, Name = "List2", CreatedAt = DateTime.UtcNow });
+
+ var api = new FakeApi();
+ var svc = BuildService(api);
+ await svc.TickAsync(CancellationToken.None);
+
+ var pushedIds = api.ReceivedLists.Select(l => l.Id).ToHashSet();
+ Assert.Contains(listId, pushedIds);
+ Assert.Contains(listId2, pushedIds);
+ }
+
+ // ---- no token = no calls ----
+
+ [Fact]
+ public async Task Tick_NoToken_SkipsCycle_NoApiCalls()
+ {
+ _ = await SeedAsync();
+ var api = new FakeApi();
+ var svc = BuildService(api, token: null);
+
+ await svc.TickAsync(CancellationToken.None);
+
+ Assert.Equal(0, api.CallCount);
+ }
+
+ // ---- already-imported task on server ----
+
+ [Fact]
+ public async Task Tick_AlreadyLocalTask_MarksImportedWithoutDuplicate()
+ {
+ var (listId, ctx, tasks, _) = await SeedAsync();
+ using var _ = ctx;
+
+ var existingId = Guid.NewGuid().ToString();
+ var existing = new TaskEntity
+ {
+ Id = existingId, ListId = listId, Title = "Existing",
+ Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
+ };
+ await tasks.AddAsync(existing);
+
+ var api = new FakeApi
+ {
+ // Server thinks this task isn't imported yet (e.g. a retry scenario)
+ UnimportedTasks = [new RemoteTask(existingId, listId, "Existing", null, DateTimeOffset.UtcNow)],
+ };
+ var svc = BuildService(api);
+ await svc.TickAsync(CancellationToken.None);
+
+ // Should still mark imported, and not create a duplicate
+ Assert.Contains(existingId, api.MarkedImported);
+ using var ctx2 = _db.CreateContext();
+ var count = (await new TaskRepository(ctx2).GetByListIdAsync(listId)).Count(t => t.Id == existingId);
+ Assert.Equal(1, count);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs
new file mode 100644
index 0000000..404351b
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs
@@ -0,0 +1,53 @@
+using ClaudeDo.Worker.Online;
+
+namespace ClaudeDo.Worker.Tests.Online;
+
+public sealed class OnlineTokenStoreTests : IDisposable
+{
+ private readonly string _tokenPath = Path.Combine(Path.GetTempPath(), $"online_token_{Guid.NewGuid():N}.bin");
+
+ public void Dispose()
+ {
+ try { File.Delete(_tokenPath); } catch { }
+ }
+
+ [Fact]
+ public void Save_Read_RoundTrips()
+ {
+ if (!OperatingSystem.IsWindows()) return; // DPAPI is Windows-only
+
+ var store = new OnlineTokenStore(_tokenPath);
+ store.Save("my-refresh-token");
+ var result = store.Read();
+ Assert.Equal("my-refresh-token", result);
+ }
+
+ [Fact]
+ public void Clear_Removes_Token()
+ {
+ if (!OperatingSystem.IsWindows()) return;
+
+ var store = new OnlineTokenStore(_tokenPath);
+ store.Save("token");
+ store.Clear();
+ Assert.Null(store.Read());
+ }
+
+ [Fact]
+ public void Read_WhenFileAbsent_Returns_Null()
+ {
+ if (!OperatingSystem.IsWindows()) return;
+
+ var store = new OnlineTokenStore(_tokenPath);
+ Assert.Null(store.Read());
+ }
+
+ [Fact]
+ public void Clear_WhenFileAbsent_DoesNotThrow()
+ {
+ if (!OperatingSystem.IsWindows()) return;
+
+ var store = new OnlineTokenStore(_tokenPath);
+ store.Clear(); // no exception expected
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Online/StaticTokenAuthProviderTests.cs b/tests/ClaudeDo.Worker.Tests/Online/StaticTokenAuthProviderTests.cs
new file mode 100644
index 0000000..47537d6
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Online/StaticTokenAuthProviderTests.cs
@@ -0,0 +1,30 @@
+using ClaudeDo.Worker.Online;
+
+namespace ClaudeDo.Worker.Tests.Online;
+
+public sealed class StaticTokenAuthProviderTests
+{
+ [Fact]
+ public async Task WithToken_Returns_Token()
+ {
+ var provider = new StaticTokenAuthProvider("my-token");
+ var result = await provider.GetAccessTokenAsync();
+ Assert.Equal("my-token", result);
+ }
+
+ [Fact]
+ public async Task WithNull_Returns_Null()
+ {
+ var provider = new StaticTokenAuthProvider(null);
+ var result = await provider.GetAccessTokenAsync();
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public async Task Default_Returns_Null()
+ {
+ var provider = new StaticTokenAuthProvider();
+ var result = await provider.GetAccessTokenAsync();
+ Assert.Null(result);
+ }
+}