diff --git a/src/Wiki.Host/Api/ApiV1.cs b/src/Wiki.Host/Api/ApiV1.cs
index 9d902b3..dea0a65 100644
--- a/src/Wiki.Host/Api/ApiV1.cs
+++ b/src/Wiki.Host/Api/ApiV1.cs
@@ -1,9 +1,10 @@
+using Wiki.Application.Encyclopedia;
using Wiki.Application.Status;
using Wiki.Application.Sync;
namespace Wiki.Host.Api;
-/// /api/v1: the read API grows here phase by phase. Phase 1 exposes status and a manual sync.
+/// /api/v1: status, and the sync and encyclopedia runs the cluster's CronJobs trigger.
public static class ApiV1
{
public static void MapApiV1(this WebApplication app)
@@ -25,5 +26,20 @@ public static class ApiV1
? Results.Json(new { error = sync.LastError }, statusCode: StatusCodes.Status502BadGateway)
: Results.Ok(summary);
}).RequireAuthorization(Auth.Policies.Write);
+
+ // Syncs first so the run plans from the current notes. The run outlives a caller that hangs up;
+ // only shutdown stops it. A run the text model stopped is a failure for the caller.
+ v1.MapPost("/encyclopedia/run", async (IServiceProvider services, SyncCoordinator sync, IHostApplicationLifetime lifetime) =>
+ {
+ var encyclopedia = services.GetService();
+ if (encyclopedia is null)
+ return Results.Json(new { error = "encyclopedia disabled" }, statusCode: StatusCodes.Status503ServiceUnavailable);
+ var ct = lifetime.ApplicationStopping;
+ await sync.RunAsync(force: false, ct);
+ var summary = await encyclopedia.RunAsync(ct);
+ return summary is null ? Results.Json(new { error = encyclopedia.LastError }, statusCode: StatusCodes.Status502BadGateway)
+ : summary.StoppedByModel ? Results.Json(summary, statusCode: StatusCodes.Status502BadGateway)
+ : Results.Ok(summary);
+ }).RequireAuthorization(Auth.Policies.Write);
}
}
diff --git a/src/Wiki.Host/Composition/EncyclopediaBackgroundService.cs b/src/Wiki.Host/Composition/EncyclopediaBackgroundService.cs
deleted file mode 100644
index 4d0ddb2..0000000
--- a/src/Wiki.Host/Composition/EncyclopediaBackgroundService.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-using Wiki.Application.Encyclopedia;
-using Wiki.Application.Sync;
-
-namespace Wiki.Host.Composition;
-
-///
-/// Reconciles the encyclopedia once a night at in ,
-/// after a sync so the catalog is current. Never runs on startup. Nothing a run or the schedule throws stops the loop or the host.
-///
-public sealed class EncyclopediaBackgroundService(
- SyncCoordinator sync,
- EncyclopediaCoordinator encyclopedia,
- EncyclopediaOptions options,
- TimeProvider clock,
- ILogger log) : BackgroundService
-{
- private static readonly TimeSpan ErrorBackoff = TimeSpan.FromMinutes(5);
-
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- TimeZoneInfo? zone = null;
- while (!stoppingToken.IsCancellationRequested)
- {
- try
- {
- zone ??= ResolveZone();
- var now = clock.GetUtcNow();
- var target = NextRun(now, zone, options.RunAt);
- log.LogInformation("next encyclopedia run at {Target:u}", target);
- await Task.Delay(target - now, clock, stoppingToken);
- // A timer may fire early; the run must not start before its target, or the next target would be the same one.
- while ((now = clock.GetUtcNow()) < target)
- await Task.Delay(target - now, clock, stoppingToken);
- await sync.RunAsync(force: false, stoppingToken);
- await encyclopedia.RunAsync(stoppingToken);
- }
- catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
- {
- break;
- }
- catch (Exception e)
- {
- log.LogError(e, "encyclopedia schedule failed; scheduling the next run after a {Backoff} pause", ErrorBackoff);
- try
- {
- await Task.Delay(ErrorBackoff, clock, stoppingToken);
- }
- catch (OperationCanceledException)
- {
- break;
- }
- }
- }
- }
-
- /// The first in strictly after ; a time skipped by a DST change runs an hour later.
- public static DateTimeOffset NextRun(DateTimeOffset now, TimeZoneInfo zone, TimeOnly runAt)
- {
- var today = TimeZoneInfo.ConvertTime(now, zone).Date;
- for (var days = 0; ; days++)
- {
- var local = DateTime.SpecifyKind(today.AddDays(days) + runAt.ToTimeSpan(), DateTimeKind.Unspecified);
- if (zone.IsInvalidTime(local)) local = local.AddHours(1);
- var candidate = new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(local, zone), TimeSpan.Zero);
- if (candidate > now) return candidate;
- }
- }
-
- private TimeZoneInfo ResolveZone()
- {
- try
- {
- return TimeZoneInfo.FindSystemTimeZoneById(options.TimeZone);
- }
- catch (Exception e) when (e is TimeZoneNotFoundException or InvalidTimeZoneException)
- {
- log.LogError(e, "time zone '{TimeZone}' cannot be resolved; scheduling in UTC", options.TimeZone);
- return TimeZoneInfo.Utc;
- }
- }
-}
diff --git a/src/Wiki.Host/Composition/SyncBackgroundService.cs b/src/Wiki.Host/Composition/SyncBackgroundService.cs
index 0dc94c7..ca98637 100644
--- a/src/Wiki.Host/Composition/SyncBackgroundService.cs
+++ b/src/Wiki.Host/Composition/SyncBackgroundService.cs
@@ -2,8 +2,8 @@ using Wiki.Application.Sync;
namespace Wiki.Host.Composition;
-/// Syncs at start, on every webhook trigger, and every interval; retries sooner after a failure.
-public sealed class SyncBackgroundService(SyncCoordinator coordinator, WikiOptions options, ILogger log) : BackgroundService
+/// Syncs at start and on every webhook trigger. The schedule lives outside: a CronJob calls POST /api/v1/sync.
+public sealed class SyncBackgroundService(SyncCoordinator coordinator, ILogger log) : BackgroundService
{
private readonly SemaphoreSlim _wake = new(0);
@@ -13,13 +13,11 @@ public sealed class SyncBackgroundService(SyncCoordinator coordinator, WikiOptio
{
while (!stoppingToken.IsCancellationRequested)
{
- var summary = await coordinator.RunAsync(force: false, stoppingToken);
- var wait = summary is null ? options.Sync.RetrySeconds : options.Sync.IntervalSeconds;
- if (summary is null)
- log.LogWarning("sync failed; retrying in {Seconds}s", wait);
+ if (await coordinator.RunAsync(force: false, stoppingToken) is null)
+ log.LogWarning("sync failed; the next trigger retries");
try
{
- await _wake.WaitAsync(TimeSpan.FromSeconds(wait), stoppingToken);
+ await _wake.WaitAsync(stoppingToken);
}
catch (OperationCanceledException)
{
diff --git a/src/Wiki.Host/Composition/WikiOptions.cs b/src/Wiki.Host/Composition/WikiOptions.cs
index b90abd2..0d60fd9 100644
--- a/src/Wiki.Host/Composition/WikiOptions.cs
+++ b/src/Wiki.Host/Composition/WikiOptions.cs
@@ -8,7 +8,6 @@ public sealed class WikiOptions
public JudgesOptions Judges { get; set; } = new();
public AnswerModelsOptions AnswerModels { get; set; } = new();
public IndexOptions Index { get; set; } = new();
- public SyncScheduleOptions Sync { get; set; } = new();
public EncyclopediaOptions Encyclopedia { get; set; } = new();
}
@@ -77,23 +76,13 @@ public sealed class FileIndexStoreOptions
public string Directory { get; set; } = "/data";
}
-public sealed class SyncScheduleOptions
-{
- /// Reconcile interval; the webhook is the fast path.
- public int IntervalSeconds { get; set; } = 1800;
- public int RetrySeconds { get; set; } = 60;
-}
-
-/// The nightly encyclopedia run. Off unless enabled; the API key is read from the answer model named by .
+/// The encyclopedia run, triggered by POST /api/v1/encyclopedia/run. Off unless enabled; the API key is read from the answer model named by .
public sealed class EncyclopediaOptions
{
public bool Enabled { get; set; } = false;
/// The answer-model entry whose Url and key (`Wiki:AnswerModels:Models:{AnswerModel}:ApiKey`) the text model uses.
public string AnswerModel { get; set; } = "claude-sonnet";
public string Model { get; set; } = "claude-sonnet-5";
- /// Local time in ; after the 04:30 distill.
- public TimeOnly RunAt { get; set; } = new(6, 0);
- public string TimeZone { get; set; } = "Europe/Berlin";
public int ArticleMaxTokens { get; set; } = 16000;
public int PlanMaxTokens { get; set; } = 32000;
public int MaxArticlesPerRun { get; set; } = 10;
diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs
index 4f87c50..1fe9d15 100644
--- a/src/Wiki.Host/Program.cs
+++ b/src/Wiki.Host/Program.cs
@@ -95,7 +95,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService>(sp =>
wiki.AnswerModels.Models.Keys.Select(sp.GetRequiredKeyedService).ToList());
builder.Services.AddSingleton(sp => sp.GetRequiredKeyedService(wiki.AnswerModels.Active));
-// ---- encyclopedia: the text model and the nightly run exist only when enabled ------------
+// ---- encyclopedia: the text model and the run exist only when enabled --------------------
var encyclopedia = wiki.Encyclopedia;
builder.Services.AddSingleton(encyclopedia);
string? encyclopediaConfigWarning = null;
@@ -120,7 +120,6 @@ if (encyclopedia.Enabled)
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
- builder.Services.AddHostedService();
}
// One adapter may serve several ports; list it once.
builder.Services.AddSingleton>(sp =>
diff --git a/src/Wiki.Host/appsettings.json b/src/Wiki.Host/appsettings.json
index 4569f4e..c8fcd86 100644
--- a/src/Wiki.Host/appsettings.json
+++ b/src/Wiki.Host/appsettings.json
@@ -31,7 +31,6 @@
}
},
"Index": { "Active": "file", "File": { "Directory": "/data" } },
- "Sync": { "IntervalSeconds": 1800, "RetrySeconds": 60 },
"Encyclopedia": { "Enabled": false }
},
"Auth": {
diff --git a/test/Wiki.Host.Tests/EncyclopediaBackgroundServiceTests.cs b/test/Wiki.Host.Tests/EncyclopediaBackgroundServiceTests.cs
deleted file mode 100644
index c873871..0000000
--- a/test/Wiki.Host.Tests/EncyclopediaBackgroundServiceTests.cs
+++ /dev/null
@@ -1,199 +0,0 @@
-using System.Text.Json;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging.Abstractions;
-using Microsoft.Extensions.Options;
-using Microsoft.Extensions.Time.Testing;
-using Wiki.Adapters.FileIndex;
-using Wiki.Application.Encyclopedia;
-using Wiki.Application.Sync;
-using Wiki.Core.Ports;
-using Wiki.Host.Composition;
-
-namespace Wiki.Host.Tests;
-
-public sealed class EncyclopediaBackgroundServiceTests : IAsyncLifetime
-{
- private static readonly TimeZoneInfo Berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
-
- /// Signals each time something arms a timer, so tests move the clock only once the loop is waiting.
- private sealed class SignalingClock : FakeTimeProvider
- {
- public SemaphoreSlim Armed { get; } = new(0);
-
- public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
- {
- var timer = base.CreateTimer(callback, state, dueTime, period);
- Armed.Release();
- return timer;
- }
- }
-
- private sealed class RecordingRun(IDocumentCatalog catalog) : IEncyclopediaRun
- {
- public List CatalogCommits { get; } = [];
- public SemaphoreSlim Ran { get; } = new(0);
- public Queue Failures { get; } = new();
-
- public async Task RunAsync(CancellationToken ct)
- {
- CatalogCommits.Add((await catalog.GetStateAsync(ct)).Commit);
- Ran.Release();
- if (Failures.TryDequeue(out var failure)) throw failure;
- return new ReconcileSummary(0, 0, 0, 0, 0, false, null, null);
- }
- }
-
- private readonly FakeStore _store = new();
- private readonly FileDocumentCatalog _catalog;
- private static readonly TimeSpan Patience = TimeSpan.FromSeconds(10);
-
- private readonly SignalingClock _clock = new();
- private readonly RecordingRun _run;
- private readonly EncyclopediaCoordinator _coordinator;
- private readonly SyncCoordinator _sync;
- private readonly string _directory = Path.Combine(Path.GetTempPath(), "wiki-encyclopedia-tests", Guid.NewGuid().ToString("N"));
- private EncyclopediaBackgroundService? _service;
-
- public EncyclopediaBackgroundServiceTests()
- {
- var files = Options.Create(new FileIndexOptions { Directory = _directory });
- _catalog = new FileDocumentCatalog(files);
- var useCase = new SyncUseCase(_store, _catalog, new FileVectorIndex(files), [new FakeEmbeddings("m")], _clock, NullLogger.Instance);
- _sync = new SyncCoordinator(useCase, _clock, NullLogger.Instance);
- _run = new RecordingRun(_catalog);
- _coordinator = new EncyclopediaCoordinator(_run, _clock, NullLogger.Instance);
- }
-
- public Task InitializeAsync() => Task.CompletedTask;
-
- public async Task DisposeAsync()
- {
- if (_service is not null) await _service.StopAsync(CancellationToken.None);
- if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true);
- }
-
- private static DateTimeOffset Berlin_(int day, int hour, int minute) =>
- new(TimeZoneInfo.ConvertTimeToUtc(new DateTime(2026, 10, day, hour, minute, 0, DateTimeKind.Unspecified), Berlin), TimeSpan.Zero);
-
- private async Task StartAtAsync(DateTimeOffset start, string timeZone = "Europe/Berlin")
- {
- _clock.SetUtcNow(start);
- _service = new EncyclopediaBackgroundService(_sync, _coordinator, new EncyclopediaOptions { TimeZone = timeZone },
- _clock, NullLogger.Instance);
- await _service.StartAsync(CancellationToken.None);
- await WaitArmedAsync();
- }
-
- /// Moves the clock without firing the pending timer; the loop is waiting, so nothing can run.
- private void AdvanceTo(DateTimeOffset target) => _clock.SetUtcNow(target);
-
- /// Moves the clock to a run time, waits for the run, then for the loop to arm its next timer.
- private async Task RunAtAsync(DateTimeOffset target)
- {
- _clock.SetUtcNow(target);
- Assert.True(await _run.Ran.WaitAsync(Patience), "the run did not start");
- await WaitArmedAsync();
- }
-
- private async Task WaitArmedAsync() => Assert.True(await _clock.Armed.WaitAsync(Patience), "the service did not arm a timer");
-
- [Fact]
- public async Task Service_RunsOncePerDayAtRunAt()
- {
- await StartAtAsync(Berlin_(24, 5, 0));
- Assert.Empty(_run.CatalogCommits);
-
- await RunAtAsync(Berlin_(24, 6, 0));
- Assert.Single(_run.CatalogCommits);
-
- AdvanceTo(Berlin_(25, 5, 59));
- Assert.Single(_run.CatalogCommits);
-
- await RunAtAsync(Berlin_(25, 6, 0));
- Assert.Equal(2, _run.CatalogCommits.Count);
- }
-
- [Fact]
- public async Task Service_DoesNotRunOnStartup()
- {
- await StartAtAsync(Berlin_(24, 7, 0));
- Assert.Empty(_run.CatalogCommits);
-
- AdvanceTo(Berlin_(25, 5, 59));
- Assert.Empty(_run.CatalogCommits);
-
- await RunAtAsync(Berlin_(25, 6, 0));
- Assert.Single(_run.CatalogCommits);
- }
-
- [Fact]
- public async Task Service_SyncsBeforeRun()
- {
- _store.Files["wiki/a.md"] = "---\ntitle: A\ntype: wiki\n---\n\nBody.\n";
- await _sync.RunAsync(force: true, CancellationToken.None);
- var first = (await _catalog.GetStateAsync(CancellationToken.None)).Commit;
- await StartAtAsync(Berlin_(24, 5, 0));
- _store.Files["wiki/b.md"] = "---\ntitle: B\ntype: wiki\n---\n\nBody.\n";
- var head = (await _store.GetHeadAsync(CancellationToken.None)).Commit;
-
- await RunAtAsync(Berlin_(24, 6, 0));
-
- var seen = Assert.Single(_run.CatalogCommits);
- Assert.NotEqual(first, seen);
- Assert.Equal(head, seen);
- }
-
- [Fact]
- public async Task Service_ReconcilerThrows_KeepsRunning()
- {
- _run.Failures.Enqueue(new JsonException("bad reply"));
- await StartAtAsync(Berlin_(24, 5, 0));
-
- await RunAtAsync(Berlin_(24, 6, 0));
- Assert.Single(_run.CatalogCommits);
- Assert.Equal("bad reply", _coordinator.LastError);
- Assert.False(_service!.ExecuteTask!.IsCompleted);
-
- await RunAtAsync(Berlin_(25, 6, 0));
- Assert.Equal(2, _run.CatalogCommits.Count);
- Assert.Null(_coordinator.LastError);
- }
-
- [Fact]
- public async Task Service_UnknownTimeZone_FallsBackToUtc()
- {
- await StartAtAsync(new DateTimeOffset(2026, 10, 24, 5, 0, 0, TimeSpan.Zero), "Mars/Olympus");
-
- await RunAtAsync(new DateTimeOffset(2026, 10, 24, 6, 0, 0, TimeSpan.Zero));
-
- Assert.Single(_run.CatalogCommits);
- }
-
- [Fact]
- public void NextRun_SkippedByDstGap_RunsAnHourLater()
- {
- // 2026-03-29 02:30 does not exist in Berlin
- var next = EncyclopediaBackgroundService.NextRun(new DateTimeOffset(2026, 3, 28, 12, 0, 0, TimeSpan.Zero), Berlin, new TimeOnly(2, 30));
-
- Assert.Equal(new DateTimeOffset(2026, 3, 29, 1, 30, 0, TimeSpan.Zero), next);
- }
-
- [Fact]
- public void Host_EncyclopediaDisabled_NoTextModelRegistered()
- {
- using var factory = new WikiHostFactory();
- Assert.Null(factory.Services.GetService());
- Assert.Null(factory.Services.GetService());
- }
-
- [Fact]
- public void Host_EncyclopediaEnabled_TextModelInAdapterHealth()
- {
- using var factory = new WikiHostFactory().WithWebHostBuilder(b => b.UseSetting("Wiki:Encyclopedia:Enabled", "true"));
-
- var adapters = factory.Services.GetRequiredService>();
-
- Assert.Contains(adapters, a => a.Name.StartsWith("anthropic-text:", StringComparison.Ordinal));
- Assert.NotNull(factory.Services.GetRequiredService());
- }
-}
diff --git a/test/Wiki.Host.Tests/EncyclopediaEndpointTests.cs b/test/Wiki.Host.Tests/EncyclopediaEndpointTests.cs
new file mode 100644
index 0000000..af7c6c0
--- /dev/null
+++ b/test/Wiki.Host.Tests/EncyclopediaEndpointTests.cs
@@ -0,0 +1,138 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Wiki.Application.Encyclopedia;
+using Wiki.Core.Ports;
+
+namespace Wiki.Host.Tests;
+
+public sealed class EncyclopediaEndpointTests
+{
+ private sealed class RecordingRun(IDocumentCatalog catalog) : IEncyclopediaRun
+ {
+ public List CatalogCommits { get; } = [];
+ public Exception? Failure { get; set; }
+ public bool StoppedByModel { get; set; }
+
+ public async Task RunAsync(CancellationToken ct)
+ {
+ CatalogCommits.Add((await catalog.GetStateAsync(ct)).Commit);
+ if (Failure is not null) throw Failure;
+ return new ReconcileSummary(3, 0, 0, 0, 0, StoppedByModel, null, null);
+ }
+ }
+
+ private static (WebApplicationFactory Host, WikiHostFactory Base, Func Run) Enabled()
+ {
+ var factory = new WikiHostFactory();
+ var host = factory.WithWebHostBuilder(b =>
+ {
+ b.UseSetting("Wiki:Encyclopedia:Enabled", "true");
+ b.ConfigureTestServices(s =>
+ {
+ s.RemoveAll();
+ s.AddSingleton(sp => new RecordingRun(sp.GetRequiredService()));
+ s.AddSingleton(sp => sp.GetRequiredService());
+ });
+ });
+ return (host, factory, () => host.Services.GetRequiredService());
+ }
+
+ private static HttpRequestMessage RunRequest(string? key = WikiHostFactory.ApiKey)
+ {
+ var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/encyclopedia/run");
+ if (key is not null) req.Headers.Add("X-Api-Key", key);
+ return req;
+ }
+
+ [Fact]
+ public void Host_EncyclopediaDisabled_NoTextModelRegistered()
+ {
+ using var factory = new WikiHostFactory();
+ Assert.Null(factory.Services.GetService());
+ Assert.Null(factory.Services.GetService());
+ }
+
+ [Fact]
+ public void Host_EncyclopediaEnabled_TextModelInAdapterHealth()
+ {
+ using var factory = new WikiHostFactory().WithWebHostBuilder(b => b.UseSetting("Wiki:Encyclopedia:Enabled", "true"));
+
+ var adapters = factory.Services.GetRequiredService>();
+
+ Assert.Contains(adapters, a => a.Name.StartsWith("anthropic-text:", StringComparison.Ordinal));
+ Assert.NotNull(factory.Services.GetRequiredService());
+ }
+
+ [Fact]
+ public async Task Run_Disabled_Is503()
+ {
+ using var factory = new WikiHostFactory();
+
+ var r = await factory.CreateClient().SendAsync(RunRequest());
+
+ Assert.Equal(HttpStatusCode.ServiceUnavailable, r.StatusCode);
+ }
+
+ [Fact]
+ public async Task Run_RequiresAnApiKey()
+ {
+ var (host, _, run) = Enabled();
+ using var _h = host;
+ var client = host.CreateClient();
+
+ Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(RunRequest(key: null))).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized, (await client.SendAsync(RunRequest("wrong"))).StatusCode);
+ Assert.Empty(run().CatalogCommits);
+ }
+
+ [Fact]
+ public async Task Run_SyncsFirstAndReturnsTheSummary()
+ {
+ var (host, factory, run) = Enabled();
+ using var _h = host;
+ var client = host.CreateClient();
+ factory.Store.Files["wiki/a.md"] = "---\ntitle: A\ntype: wiki\n---\n\nBody.\n";
+ var head = (await factory.Store.GetHeadAsync(CancellationToken.None)).Commit;
+
+ var r = await client.SendAsync(RunRequest());
+
+ Assert.Equal(HttpStatusCode.OK, r.StatusCode);
+ Assert.Equal(head, Assert.Single(run().CatalogCommits));
+ var body = await r.Content.ReadFromJsonAsync();
+ Assert.Equal(3, body.GetProperty("planned").GetInt32());
+ }
+
+ [Fact]
+ public async Task Run_Failed_Is502WithTheError()
+ {
+ var (host, _, run) = Enabled();
+ using var _h = host;
+ var client = host.CreateClient();
+ run().Failure = new JsonException("bad reply");
+
+ var r = await client.SendAsync(RunRequest());
+
+ Assert.Equal(HttpStatusCode.BadGateway, r.StatusCode);
+ Assert.Equal("bad reply", (await r.Content.ReadFromJsonAsync()).GetProperty("error").GetString());
+ }
+
+ [Fact]
+ public async Task Run_StoppedByModel_Is502WithTheSummary()
+ {
+ var (host, _, run) = Enabled();
+ using var _h = host;
+ var client = host.CreateClient();
+ run().StoppedByModel = true;
+
+ var r = await client.SendAsync(RunRequest());
+
+ Assert.Equal(HttpStatusCode.BadGateway, r.StatusCode);
+ Assert.True((await r.Content.ReadFromJsonAsync()).GetProperty("stoppedByModel").GetBoolean());
+ }
+}