feat(worker): Online Inbox sync engine (Phase 1)

Optional, opt-in (online_inbox.enabled, default false → zero network).
Worker-side reconcile loop: pull web-created tasks down as Idle, push the
list catalog and the Idle backlog mirror up. Auth behind IOnlineAuthProvider
(StaticTokenAuthProvider default; ZitadelAuthProvider stubbed for Phase 2).
DPAPI refresh-token store. 35 tests, no real network/Zitadel/Claude.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-10 09:55:20 +02:00
parent 8cbe1adb32
commit 1ac9ced0bd
22 changed files with 1196 additions and 0 deletions

View File

@@ -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;
}
/// <summary>
/// Validates that <paramref name="baseUrl"/> is HTTPS or a loopback address.
/// Throws <see cref="InvalidOperationException"/> for non-HTTPS non-loopback URLs.
/// </summary>
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<RemoteList> 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<IReadOnlyList<RemoteTask>> 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<List<RemoteTask>>(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<MirrorTask> 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<HttpRequestMessage> 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}");
}
}