From 0dfa31c69528353d5eca4506bb2c4f8ca375a65d Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 21:24:30 -0400 Subject: [PATCH 1/7] Add bulk entry updates as a job and the get_job tool --- CONTRIBUTING.md | 2 +- .../Builtin/Jobs/BulkEntryUpdates.cs | 116 ++++++++++ src/FluentCMS.Core/Builtin/Tools/BulkTools.cs | 166 ++++++++++++++ src/FluentCMS.Core/Builtin/Tools/JobTools.cs | 45 ++++ src/FluentCMS.Core/Builtin/Tools/ToolJson.cs | 18 ++ .../Tools/ToolArgumentsReader.cs | 17 ++ .../Mcp/BulkToolsTests.cs | 209 ++++++++++++++++++ .../Mcp/ConnectedAgent.cs | 22 +- .../Mcp/McpAgents.cs | 5 +- .../FluentCMS.Tests/Tools/McpCoverageTests.cs | 3 +- .../Tools/ToolProviderConformanceTests.cs | 10 + 11 files changed, 607 insertions(+), 6 deletions(-) create mode 100644 src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs create mode 100644 src/FluentCMS.Core/Builtin/Tools/BulkTools.cs create mode 100644 src/FluentCMS.Core/Builtin/Tools/JobTools.cs create mode 100644 tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b079168a0..790b9594f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ Tests run against SQLite in-process. To run the PostgreSQL tests as well, set `F To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted), `JobQueue__WorkerCount` and `Content__DeletedRetention` (how long a deleted entry can be restored, `30.00:00:00` by default), and from an optional `appsettings.json` next to the binary. -Agents reach the MCP server (ADR-0007) through either mode. The web host serves streamable HTTP at `/mcp` in stateless mode: each request stands alone, no session id is issued, and a GET answers 405 because the server never opens a stream to the client. `fluentcms mcp` serves the same server over standard input and output to an agent that starts it as a local process, for example with `dotnet run --project src/FluentCMS.Host -- mcp` as its command. It reads the same settings as the web host, so pass it the web host's `Database__ConnectionString` and `DataProtection__KeysDirectory` to work on the same database and key ring. Relative paths resolve against the process's working directory, so give an agent absolute ones. Standard output carries only protocol messages, log lines go to standard error, and the process stops when its standard input closes. The stdio mode runs with the local user's database access, so it is meant for development and single-user setups. Clients on protocol revision 2026-07-28 connect with `server/discover`, earlier ones with `initialize`. Agents read content types, the schema document and change sets with the read-only tools, and propose changes with `open_change_set`, `create_content_type`, `revise_content_type` and `submit_change_set`. A write stores an unpublished version and its operation in the agent's change set together, and the version is not published until a person approves and publishes the set; no tool approves, rejects or publishes. +Agents reach the MCP server (ADR-0007) through either mode. The web host serves streamable HTTP at `/mcp` in stateless mode: each request stands alone, no session id is issued, and a GET answers 405 because the server never opens a stream to the client. `fluentcms mcp` serves the same server over standard input and output to an agent that starts it as a local process, for example with `dotnet run --project src/FluentCMS.Host -- mcp` as its command. It reads the same settings as the web host, so pass it the web host's `Database__ConnectionString` and `DataProtection__KeysDirectory` to work on the same database and key ring. Relative paths resolve against the process's working directory, so give an agent absolute ones. Standard output carries only protocol messages, log lines go to standard error, and the process stops when its standard input closes. The stdio mode runs with the local user's database access, so it is meant for development and single-user setups. Clients on protocol revision 2026-07-28 connect with `server/discover`, earlier ones with `initialize`. Agents read content types, the schema document and change sets with the read-only tools, and propose changes with `open_change_set`, `create_content_type`, `revise_content_type` and `submit_change_set`. A write stores an unpublished version and its operation in the agent's change set together, and the version is not published until a person approves and publishes the set; no tool approves, rejects or publishes. `bulk_update_entries` opens a change set for many entry or entity updates and returns its id and a job id at once; the job applies the updates in the background (see Background jobs), and `get_job` shows its progress and any item that failed. Every MCP call runs as an agent and needs that agent's key, issued on `/agents/new`. Over HTTP the key goes in the `Authorization: Bearer ` header of each request; `/mcp` reads no other credential, so a signed-in person's admin cookie is ignored there. Over stdio the key goes in the `FLUENTCMS_AGENT_KEY` environment variable of the process, which an agent's MCP configuration can set; it is never read from a command-line argument, which other users can see in process listings. Both transports check the key on every request or message, so an expired or revoked key is refused from its next call on, with a message that says why. Never put a key in a URL: the server ignores one there, but ASP.NET Core logs request URLs once `Logging__LogLevel__Microsoft.AspNetCore` is below its shipped `Warning`. Every tool call is recorded in the `ToolCalls` table with the agent, the tool, the outcome and how long it took. An agent can add the model it runs on and the task it works on as strings in the call's `_meta`, under `fluentcms/model` and `fluentcms/task`, and the record keeps both. Arguments and results are not recorded; the audit log records every change a tool makes. diff --git a/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs new file mode 100644 index 000000000..6d46387d6 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Jobs; + +// The background half of bulk_update_entries (MCP-08): each item's new version and its operation in the change set commit together, +// one item at a time, as the agent that asked, so its permissions, scopes and caps apply to every item. An item that fails is +// recorded and the rest go on. An item already in the set is skipped, so a retried job repeats nothing. +public sealed class BulkEntryUpdates : IJobHandler +{ + public const string Name = "bulk.entry_updates"; + + private const int MaxErrorsListed = 20; + + public string Type => Name; + + public static JsonElement Payload(Guid changeSetId, IReadOnlyList items) + { + return JsonSerializer.SerializeToElement(new Request(changeSetId, items), JsonSerializerOptions.Web); + } + + public async Task RunAsync(JobContext context, CancellationToken cancellationToken) + { + var request = context.Job.Payload.Deserialize(JsonSerializerOptions.Web) + ?? throw new InvalidOperationException($"Job {context.Job.Id} has no bulk update in its payload."); + var services = context.Services; + var changeSets = services.GetRequiredService(); + + var set = await changeSets.GetAsync(request.ChangeSetId, cancellationToken); + if (!set.IsSuccess) + { + return Result.Failure(set.Errors); + } + + var applied = set.Value.Operations.Select(operation => operation.ItemId).ToHashSet(); + var failures = new List(); + var total = request.Items.Count; + + for (var index = 0; index < total; index++) + { + var item = request.Items[index]; + if (!applied.Contains(item.Id)) + { + var updated = await ApplyAsync(services, request.ChangeSetId, item, cancellationToken); + failures.AddRange(updated.Errors.Select(error => new Error(error.Code, $"items[{index}] {item.Id}: {error.Message}"))); + applied.Add(item.Id); + } + + var message = $"Updated {index + 1 - failures.Count} of {total}" + (failures.Count > 0 ? $"; {failures.Count} failed." : "."); + if (!await context.ReportProgressAsync(index + 1, total, message, cancellationToken)) + { + // Another worker has the job now and goes on from what is already in the set. + return Result.Success(); + } + } + + if (failures.Count == 0) + { + return Result.Success(); + } + + var listed = failures.Take(MaxErrorsListed).ToList(); + if (failures.Count > MaxErrorsListed) + { + listed.Add(new Error("bulk_items_failed", $"{failures.Count - MaxErrorsListed} more items failed.")); + } + + return Result.Failure(listed); + } + + // The update and its operation commit together or not at all; leaving the unit of work uncommitted undoes both. + private static async Task ApplyAsync(IServiceProvider services, Guid changeSetId, BulkEntryUpdate item, CancellationToken cancellationToken) + { + await using var scope = await services.GetRequiredService().BeginAsync(cancellationToken); + + var entry = await services.GetRequiredService().UpdateAsync(item.Id, item.BaseVersion, item.Fields, cancellationToken); + if (!entry.IsSuccess) + { + return Result.Failure(entry.Errors); + } + + var type = await services.GetRequiredService().GetAsync(entry.Value.ContentType, cancellationToken); + if (!type.IsSuccess) + { + return Result.Failure(type.Errors); + } + + var kind = type.Value.Definition.IsEntity ? ItemKind.Entity : ItemKind.Entry; + var operation = entry.Value.PublishedVersion is { } published + ? ChangeOperation.Create(OperationKind.Update, kind, entry.Value.Id, published, entry.Value.Latest.Version) + : ChangeOperation.Create(OperationKind.Create, kind, entry.Value.Id, null, entry.Value.Latest.Version); + if (!operation.IsSuccess) + { + return Result.Failure(operation.Errors); + } + + var added = await services.GetRequiredService().AddOperationAsync(changeSetId, operation.Value, cancellationToken); + if (!added.IsSuccess) + { + return Result.Failure(added.Errors); + } + + await scope.CommitAsync(cancellationToken); + return Result.Success(); + } + + private sealed record Request(Guid ChangeSetId, IReadOnlyList Items); +} + +public sealed record BulkEntryUpdate(Guid Id, int BaseVersion, JsonElement Fields); diff --git a/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs b/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs new file mode 100644 index 000000000..b8d8d8cad --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Builtin.Jobs; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Sites; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Tools; + +// Bulk writes (MCP-08): the call opens a change set and enqueues the work in one unit of work, and returns both ids at once; the +// job applies the items in the background, and get_job shows how far it got. +public sealed class BulkTools : IToolProvider +{ + public const int MaxItems = 1000; + + private const string ItemsForm = "an array of {\"id\", \"base_version\", \"fields\"} objects, one per entry or entity"; + + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "bulk_update_entries", + "Propose updates of many entries at once", + "Proposes new fields for many entries or entities in one call: it opens a change set for them and returns its id and a " + + "job id at once, and the updates are applied in the background, each as update_entry would. Follow the job with " + + "get_job; an item that fails, such as one updated since you read it, is listed there and the rest go on. Once the " + + "job has succeeded, submit the set with submit_change_set. The list may not be longer than your items-per-change-set cap.", + ToolSchemas.Object( + ToolSchemas.Json( + "items", + $"The updates, at most {MaxItems}: each names an entry or entity, the latest version you read and its whole new fields.", + new JsonObject + { + ["type"] = "array", + ["minItems"] = 1, + ["maxItems"] = MaxItems, + ["items"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonObject + { + ["id"] = new JsonObject { ["type"] = "string", ["format"] = "uuid" }, + ["base_version"] = new JsonObject { ["type"] = "integer", ["minimum"] = 1 }, + ["fields"] = new JsonObject { ["type"] = "object" }, + }, + ["required"] = new JsonArray("id", "base_version", "fields"), + ["additionalProperties"] = false, + }, + }, + JsonNode.Parse("""[{"id": "0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e", "base_version": 2, "fields": {"title": "Widget", "price": 19.99}}]""")!), + ToolSchemas.String("rationale", "Why the updates are made, for the person who reviews them.", "Sync prices from the catalog export."), + ToolSchemas.Strings("sources", "Where the updates come from, such as URLs or file names.", "exports/catalog-2026-10-19.csv", required: false)), + readOnly: false, + UpdateAsync), + ]; + + private static async Task UpdateAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var list = arguments.RequiredArray("items", ItemsForm); + var rationale = arguments.RequiredString("rationale", "Sync prices from the catalog export."); + var sources = arguments.OptionalStrings("sources", "exports/catalog-2026-10-19.csv") ?? []; + var errors = new List(arguments.Errors()); + var items = list is { } elements ? ReadItems(elements, errors) : []; + if (errors.Count > 0) + { + return ToolResult.Failure(errors); + } + + var services = call.Services; + var initiator = services.GetRequiredService().Current + ?? throw new InvalidOperationException("No initiator is known for this call; a change set records who opened it (CHG-01)."); + + // Refused ahead of any write, so a refused cap keeps its security event (SEC-08) and no set is opened for nothing. + var planned = ChangeSet.Open(Site.Default.Id, rationale!, sources, initiator); + if (!planned.IsSuccess) + { + return ToolResult.Failure(planned.Errors); + } + + var permissions = services.GetRequiredService(); + foreach (var check in new Func>[] + { + () => permissions.CheckNewChangeSetAsync(cancellationToken), + () => permissions.CheckChangeSetSizeAsync(planned.Value, adding: items.Count, cancellationToken), + }) + { + var allowed = await check(); + if (!allowed.IsSuccess) + { + return ToolResult.Failure(allowed.Errors); + } + } + + // The set and the job commit together, so there is never a job without its set or a set nobody is working on. + await using var scope = await services.GetRequiredService().BeginAsync(cancellationToken); + + var set = await services.GetRequiredService().OpenAsync(new NewChangeSet(rationale!, sources), cancellationToken); + if (!set.IsSuccess) + { + return ToolResult.Failure(set.Errors); + } + + var job = await services.GetRequiredService().EnqueueAsync( + new NewJob(BulkEntryUpdates.Name, BulkEntryUpdates.Payload(set.Value.Id, items)) { Subject = new JobSubject("change_set", set.Value.Id) }, + cancellationToken); + + await scope.CommitAsync(cancellationToken); + + return ToolResult.Success(new JsonObject + { + ["changeSet"] = ToolJson.ChangeSet(set.Value), + ["job"] = ToolJson.Job(job), + ["next"] = $"The {items.Count} updates are applied in the background. Call get_job with id {job.Id} until it has succeeded, " + + $"then submit_change_set with id {set.Value.Id}.", + }); + } + + // Every malformed item is reported at once by its index, before anything is opened. + private static List ReadItems(JsonElement list, List errors) + { + var items = new List(); + var seen = new HashSet(); + var index = 0; + + foreach (var element in list.EnumerateArray()) + { + var reader = new ToolArgumentsReader(element); + var id = reader.RequiredGuid("id"); + var baseVersion = reader.RequiredPositiveInteger("base_version"); + var fields = reader.RequiredObject("fields", "an object with the entry's fields"); + var itemErrors = reader.Errors(); + + errors.AddRange(itemErrors.Select(error => new Error(error.Code, $"items[{index}]: {error.Message}"))); + if (itemErrors.Count == 0) + { + if (seen.Add(id!.Value)) + { + items.Add(new BulkEntryUpdate(id.Value, baseVersion!.Value, fields!.Value.Clone())); + } + else + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, $"items[{index}]: {id} is already in the list; give each entry once.")); + } + } + + index++; + } + + if (index == 0) + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, "'items' is empty: give at least one update.")); + } + else if (index > MaxItems) + { + errors.Add(new Error(ToolErrorCodes.ArgumentInvalid, $"'items' has {index} updates and a call takes at most {MaxItems}; split them over several calls.")); + } + + return items; + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/JobTools.cs b/src/FluentCMS.Core/Builtin/Tools/JobTools.cs new file mode 100644 index 000000000..aea26b199 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Tools/JobTools.cs @@ -0,0 +1,45 @@ +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; +using FluentCMS.Core.Tools; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Tools; + +// How far background work an agent started has got (MCP-08). An agent reads its own jobs only. +public sealed class JobTools : IToolProvider +{ + public IReadOnlyList Tools { get; } = + [ + new AgentTool( + "get_job", + "Get a job's progress", + "Returns one of your background jobs, such as the one bulk_update_entries started: its state (queued, running, " + + "succeeded, failed or dead), its progress as done of total with a message, its attempts and its errors. A failed job " + + "lists what failed; a dead one stopped on an error another attempt did not fix. Call it again until the state is " + + "succeeded, failed or dead.", + ToolSchemas.Object(ToolSchemas.Uuid("id", "The job's id, as the call that started it returned it.")), + readOnly: true, + GetAsync), + ]; + + private static async Task GetAsync(ToolInvocation call, CancellationToken cancellationToken) + { + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + if (arguments.Errors() is { Count: > 0 } errors) + { + return ToolResult.Failure(errors); + } + + var job = await call.Services.GetRequiredService().GetAsync(id!.Value, cancellationToken); + var caller = call.Services.GetRequiredService().Current; + + // Another caller's job reads as missing, so its existence says nothing about someone else's work. + return job.IsSuccess && job.Value.Initiator == caller + ? ToolResult.Success(ToolJson.Job(job.Value)) + : ToolResult.Failure( + [new Error(JobErrorCodes.JobNotFound, $"You have no job {id}.")], + "A job's id comes back from the call that started it, such as bulk_update_entries."); + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs index 4fb46c413..a678bf5d0 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -3,6 +3,7 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Content; using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; using FluentCMS.Core.Schema; namespace FluentCMS.Core.Builtin.Tools; @@ -159,6 +160,23 @@ public static JsonObject Usage(EntryUsage usage) }; } + public static JsonObject Job(Job job) + { + return new JsonObject + { + ["id"] = job.Id.ToString(), + ["type"] = job.Type, + ["state"] = Name(job.State), + ["subject"] = job.Subject is { } subject ? new JsonObject { ["type"] = subject.Type, ["id"] = subject.Id.ToString() } : null, + ["progress"] = new JsonObject { ["done"] = job.Progress.Done, ["total"] = job.Progress.Total, ["message"] = job.Progress.Message }, + ["attempts"] = job.Attempts, + ["maxAttempts"] = job.MaxAttempts, + ["lastError"] = job.LastError, + ["createdAt"] = job.CreatedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["finishedAt"] = job.FinishedAt?.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + }; + } + private static JsonObject Operation(ChangeOperation operation) { return new JsonObject diff --git a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs index 3e51944e2..520ebc1d7 100644 --- a/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs +++ b/src/FluentCMS.Core/Tools/ToolArgumentsReader.cs @@ -49,6 +49,23 @@ public ToolArgumentsReader(JsonElement arguments) return null; } + public JsonElement? RequiredArray(string name, string form) + { + if (Read(name) is not { } value) + { + Missing(name, form); + return null; + } + + if (value.ValueKind == JsonValueKind.Array) + { + return value; + } + + Invalid(name, $"must be a JSON array: {form}"); + return null; + } + public JsonElement? RequiredObject(string name, string form) { if (Read(name) is not { } value) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs new file mode 100644 index 000000000..da932029d --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs @@ -0,0 +1,209 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; +using FluentCMS.Core.Builtin.Jobs; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Tools; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Mcp; + +public sealed class BulkToolsTests +{ + private static readonly Dictionary FastQueue = new() + { + ["JobQueue:PollingInterval"] = "00:00:00.050", + ["JobQueue:RetryDelay"] = "00:00:00.050", + }; + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_bulk_update_of_180_entities_returns_the_change_set_at_once_and_reports_progress_until_it_is_ready(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); + var products = await CreateProductsAsync(agent, 180); + + var started = await agent.CallAsync("bulk_update_entries", new() + { + ["items"] = products.Select(id => Item(id, 1, 29.99m)).ToArray(), + ["rationale"] = "Sync prices from the catalog export.", + ["sources"] = new[] { "exports/catalog-2026-10-19.csv" }, + }); + var changeSetId = (string)started["changeSet"]!["id"]!; + var jobId = (string)started["job"]!["id"]!; + var finished = await FinishedAsync(agent, jobId); + + Assert.Equal("open", (string?)started["changeSet"]!["state"]); + Assert.Equal(changeSetId, (string?)started["job"]!["subject"]!["id"]); + Assert.Equal("succeeded", (string?)finished["state"]); + Assert.Equal((180, 180), ((int)finished["progress"]!["done"]!, (int)finished["progress"]!["total"]!)); + Assert.Equal("Updated 180 of 180.", (string?)finished["progress"]!["message"]); + + var set = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse(changeSetId))); + Assert.Equal(180, set.Operations.Count); + Assert.All(set.Operations, operation => Assert.Equal((ItemKind.Entity, 2), (operation.ItemKind, operation.ProposedVersion!.Value))); + Assert.Equal(agent.Credential.Initiator, set.Initiator); + + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = changeSetId }); + Assert.Equal("checks_running", (string?)submitted["changeSet"]!["state"]); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_item_that_fails_is_listed_by_its_id_and_the_rest_go_into_the_set(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); + var products = await CreateProductsAsync(agent, 3); + + var started = await agent.CallAsync("bulk_update_entries", new() + { + ["items"] = new[] { Item(products[0], 1, 1m), Item(products[1], 5, 2m), Item(products[2], 1, 3m) }, + ["rationale"] = "Sync prices.", + }); + var finished = await FinishedAsync(agent, (string)started["job"]!["id"]!); + + Assert.Equal("failed", (string?)finished["state"]); + Assert.Equal(1, (int)finished["attempts"]!); + Assert.Contains($"items[1] {products[1]}", (string?)finished["lastError"]); + Assert.Equal("Updated 2 of 3; 1 failed.", (string?)finished["progress"]!["message"]); + var set = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse((string)started["changeSet"]!["id"]!))); + Assert.Equal([products[0], products[2]], set.Operations.Select(operation => operation.ItemId)); + } + + [Fact] + public async Task A_list_past_the_cap_or_with_a_malformed_item_is_refused_before_a_set_is_opened() + { + await using var agent = await ConnectedAgent.StartAsync(DatabaseProviders.Sqlite, itemsPerChangeSet: 2, settings: FastQueue); + var products = await CreateProductsAsync(agent, 3); + + var overCap = await agent.FailAsync("bulk_update_entries", new() + { + ["items"] = products.Select(id => Item(id, 1, 1m)).ToArray(), + ["rationale"] = "Too many.", + }); + var malformed = await agent.FailAsync("bulk_update_entries", new() + { + ["items"] = new object[] { Item(products[0], 1, 1m), new Dictionary { ["id"] = "42", ["fields"] = new { } }, Item(products[0], 1, 1m) }, + ["rationale"] = "Malformed.", + }); + + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, (string?)overCap["errors"]![0]!["code"]); + Assert.Equal( + [(ToolErrorCodes.ArgumentInvalid, "items[1]"), (ToolErrorCodes.ArgumentMissing, "items[1]"), (ToolErrorCodes.ArgumentInvalid, "items[2]")], + malformed["errors"]!.AsArray().Select(error => ((string)error!["code"]!, ((string)error["message"]!).Split(':')[0]))); + Assert.Empty(await agent.QueryAsync(context => context.ChangeSets.AsNoTracking().ToListAsync())); + Assert.Empty(await agent.QueryAsync(context => context.Jobs.AsNoTracking().ToListAsync())); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_job_run_again_after_stopping_partway_adds_each_item_once(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + var products = await CreateProductsAsync(agent, 3); + var set = await agent.AsAgentAsync(async services => (await services.GetRequiredService().OpenAsync(new NewChangeSet("Sync prices.", []))).Value); + var payload = BulkEntryUpdates.Payload(set.Id, [.. products.Select(id => new BulkEntryUpdate(id, 1, Fields(5m)))]); + var job = new Job + { + Id = Guid.NewGuid(), + Type = BulkEntryUpdates.Name, + Payload = payload, + State = JobState.Running, + Attempts = 1, + MaxAttempts = 3, + CreatedAt = DateTimeOffset.UtcNow, + ScheduledAt = DateTimeOffset.UtcNow, + }; + + // The first run stops after the first item, as a worker that lost its lease would; the second runs the whole job. + var first = await agent.AsAgentAsync(services => + new BulkEntryUpdates().RunAsync(new JobContext(job, services, (progress, _) => Task.FromResult(progress.Done < 1)), CancellationToken.None)); + var second = await agent.AsAgentAsync(services => + new BulkEntryUpdates().RunAsync(new JobContext(job, services, (_, _) => Task.FromResult(true)), CancellationToken.None)); + + Assert.True(first.IsSuccess); + Assert.True(second.IsSuccess); + var stored = await agent.AsPersonAsync(directory => directory.GetAsync(set.Id)); + Assert.Equal(products, stored.Operations.Select(operation => operation.ItemId)); + foreach (var id in products) + { + Assert.Equal(2, (await agent.AsPersonAsync(entries => entries.GetAsync(id))).Latest.Version); + } + } + + [Fact] + public async Task Get_job_shows_an_agent_its_own_jobs_only() + { + await using var agent = await ConnectedAgent.StartAsync(DatabaseProviders.Sqlite, settings: FastQueue); + var products = await CreateProductsAsync(agent, 1); + var started = await agent.CallAsync("bulk_update_entries", new() { ["items"] = new[] { Item(products[0], 1, 1m) }, ["rationale"] = "One price." }); + var someoneElses = await agent.AsPersonAsync(async services => + { + await using var work = await services.GetRequiredService().BeginAsync(); + var job = await services.GetRequiredService().EnqueueAsync(new NewJob("test.someone-else", default) { RunAt = DateTimeOffset.UtcNow.AddDays(1) }); + await work.CommitAsync(); + return Core.Results.Result.Success(job); + }); + + var own = await agent.CallAsync("get_job", new() { ["id"] = (string)started["job"]!["id"]! }); + var other = await agent.FailAsync("get_job", new() { ["id"] = someoneElses.Id.ToString() }); + + Assert.Equal(BulkEntryUpdates.Name, (string?)own["type"]); + Assert.Equal(JobErrorCodes.JobNotFound, (string?)other["errors"]![0]!["code"]); + } + + private static async Task> CreateProductsAsync(ConnectedAgent agent, int count) + { + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewContentType( + "Product", + "product", + new ContentTypeDefinition([new FieldDefinition("name", "text"), new FieldDefinition("price", "number")], isEntity: true)))); + + var ids = new List(); + for (var index = 0; index < count; index++) + { + var entry = await agent.AsPersonAsync(entries => entries.CreateAsync(new NewEntry("product", Fields(10m + index)))); + ids.Add(entry.Id); + } + + return ids; + } + + private static Dictionary Item(Guid id, int baseVersion, decimal price) + { + return new() { ["id"] = id.ToString(), ["base_version"] = baseVersion, ["fields"] = Fields(price) }; + } + + private static JsonElement Fields(decimal price) + { + return JsonDocument.Parse($$"""{"name": {{Fact("\"Widget\"")}}, "price": {{Fact(price.ToString(System.Globalization.CultureInfo.InvariantCulture))}}}""").RootElement.Clone(); + } + + private static string Fact(string value) + { + return $$"""{"value": {{value}}, "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}"""; + } + + private static async Task FinishedAsync(ConnectedAgent agent, string jobId) + { + var deadline = DateTime.UtcNow.AddSeconds(60); + while (true) + { + var job = await agent.CallAsync("get_job", new() { ["id"] = jobId }); + if ((string?)job["state"] is "succeeded" or "failed" or "dead" || DateTime.UtcNow > deadline) + { + return job; + } + + await Task.Delay(100); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs index 6c322813d..10d7bec02 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs @@ -3,6 +3,7 @@ using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -25,11 +26,12 @@ public static async Task StartAsync( string provider, int itemsPerChangeSet = 200, IReadOnlyList? types = null, - AgentOperation[]? operations = null) + AgentOperation[]? operations = null, + IReadOnlyDictionary? settings = null) { var install = new TemporaryInstall(); var database = await TemporaryDatabase.CreateAsync(provider); - var host = await AdminHost.StartAsync(install, settings: database.Configuration); + var host = await AdminHost.StartAsync(install, settings: new Dictionary(database.Configuration.Concat(settings ?? new Dictionary()))); var administrator = await host.CreateFirstAdministratorAsync(); var issued = await McpAgents.IssueAsync(host, administrator, new NewAgentCredential( "schema-agent", @@ -60,6 +62,22 @@ public async Task AsPersonAsync(Func>(administrator, action)).Value; } + // Runs as the agent, as a call over MCP would, for work a test drives without a tool. + public async Task AsAgentAsync(Func> action) + { + var accessor = host.App.Services.GetRequiredService(); + await using var scope = host.App.Services.CreateAsyncScope(); + accessor.HttpContext = new DefaultHttpContext { RequestServices = scope.ServiceProvider, User = AgentPrincipal.For(Credential, "test") }; + try + { + return await action(scope.ServiceProvider); + } + finally + { + accessor.HttpContext = null; + } + } + public Task CreateAsPersonAsync(string slug) { return AsPersonAsync(directory => diff --git a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs index e802125db..fffdf7568 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -21,8 +21,9 @@ internal static class McpAgents // The built-in tools every server lists, sorted by name. public static readonly string[] BuiltInTools = [ - "create_content_type", "create_entry", "delete_entry", "get_change_set", "get_change_set_diff", "get_content_type", "get_entry", - "get_entry_history", "get_resolved_entry", "get_schema_document", "get_where_used", "list_content_types", "list_entries", + "bulk_update_entries", "create_content_type", "create_entry", "delete_entry", "get_change_set", "get_change_set_diff", + "get_content_type", "get_entry", "get_entry_history", "get_job", "get_resolved_entry", "get_schema_document", "get_where_used", + "list_content_types", "list_entries", "open_change_set", "reopen_change_set", "revise_content_type", "submit_change_set", "update_entry", ]; diff --git a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs index 519b11132..fd3f8324d 100644 --- a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs +++ b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs @@ -31,7 +31,8 @@ public sealed class McpCoverageTests Missing("DSK", "Read review decisions, read-only", "#2422"), Missing("MED", "Upload and describe media", "#2427"), Missing("Search", "Search entries and entities", "#2430"), - Missing("MCP-08", "Bulk operations that return a change set id and report progress", "#2423"), + new("MCP-08", "Bulk updates of entries and entities that return a change set id at once", ["bulk_update_entries"]), + new("MCP-08", "Read the progress of background work", ["get_job"]), ]; // A person approves, rejects, publishes and reverts; no tool may (MCP-06, SEC-02). Tool names start with their verb. diff --git a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs index 88d593f76..14c4b0722 100644 --- a/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs +++ b/tests/FluentCMS.Tests/Tools/ToolProviderConformanceTests.cs @@ -114,3 +114,13 @@ public sealed class EntryToolsConformanceTests : ToolProviderConformanceTests { protected override object Provider { get; } = new EntryTools(); } + +public sealed class BulkToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new BulkTools(); +} + +public sealed class JobToolsConformanceTests : ToolProviderConformanceTests +{ + protected override object Provider { get; } = new JobTools(); +} From 872caa32dda3ccbb4e3715f1051a36e4217e5e3b Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 18 Oct 2026 21:36:43 -0400 Subject: [PATCH 2/7] Give the bulk test time for a loaded machine --- tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs index da932029d..578a9cfb0 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs @@ -194,7 +194,8 @@ private static string Fact(string value) private static async Task FinishedAsync(ConnectedAgent agent, string jobId) { - var deadline = DateTime.UtcNow.AddSeconds(60); + // The job takes a few seconds for 180 items on its own; a loaded test machine can take far longer. + var deadline = DateTime.UtcNow.AddMinutes(3); while (true) { var job = await agent.CallAsync("get_job", new() { ["id"] = jobId }); From 8ee2926d02a66bcffff81f8a63fb93f4337546f8 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 07:57:59 -0400 Subject: [PATCH 3/7] Fail a bulk job whose payload cannot be read instead of throwing --- .../Builtin/Jobs/BulkEntryUpdates.cs | 24 +++++++++++-- .../Jobs/BulkEntryUpdatesTests.cs | 35 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 tests/FluentCMS.Tests/Jobs/BulkEntryUpdatesTests.cs diff --git a/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs index 6d46387d6..78d358c9b 100644 --- a/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs +++ b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs @@ -25,10 +25,30 @@ public static JsonElement Payload(Guid changeSetId, IReadOnlyList(JsonSerializerOptions.Web) is { ChangeSetId: var id, Items: not null } request + && id != Guid.Empty + ? request + : null; + } + catch (JsonException) + { + return null; + } + } + public async Task RunAsync(JobContext context, CancellationToken cancellationToken) { - var request = context.Job.Payload.Deserialize(JsonSerializerOptions.Web) - ?? throw new InvalidOperationException($"Job {context.Job.Id} has no bulk update in its payload."); + // Another attempt reads the same payload, so one this handler cannot read is a failure, not an exception to retry. + if (Read(context.Job.Payload) is not { } request) + { + return Result.Failure("payload_invalid", "A bulk update names its change set as changeSetId and its items as a list of id, baseVersion and fields."); + } + var services = context.Services; var changeSets = services.GetRequiredService(); diff --git a/tests/FluentCMS.Tests/Jobs/BulkEntryUpdatesTests.cs b/tests/FluentCMS.Tests/Jobs/BulkEntryUpdatesTests.cs new file mode 100644 index 000000000..f122c774b --- /dev/null +++ b/tests/FluentCMS.Tests/Jobs/BulkEntryUpdatesTests.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using FluentCMS.Core.Builtin.Jobs; +using FluentCMS.Core.Jobs; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Jobs; + +public sealed class BulkEntryUpdatesTests +{ + // Another attempt reads the same payload, so the job fails at once instead of throwing to be retried. + [Theory] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("{\"changeSetId\": \"not-an-id\", \"items\": []}")] + [InlineData("{\"changeSetId\": \"0192f3a1-7c2e-7a4b-9d3e-1f2a3b4c5d6e\"}")] + public async Task A_payload_it_cannot_read_is_a_failure(string payload) + { + var job = new Job + { + Id = Guid.CreateVersion7(), + Type = BulkEntryUpdates.Name, + Payload = JsonDocument.Parse(payload).RootElement.Clone(), + State = JobState.Running, + Attempts = 1, + MaxAttempts = NewJob.DefaultMaxAttempts, + CreatedAt = DateTimeOffset.UtcNow, + ScheduledAt = DateTimeOffset.UtcNow, + }; + await using var services = new ServiceCollection().BuildServiceProvider(); + + var result = await new BulkEntryUpdates().RunAsync(new JobContext(job, services, (_, _) => Task.FromResult(true)), CancellationToken.None); + + Assert.Equal("payload_invalid", Assert.Single(result.Errors).Code); + } +} From 0971a0e3aaa5989598f3aa6dcc795496b3bc04d4 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 25 Oct 2026 20:16:20 -0400 Subject: [PATCH 4/7] Count only bulk jobs in the refused-list test --- tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs index 7e1a4397a..ed9dd7eab 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs @@ -103,7 +103,7 @@ public async Task A_list_past_the_cap_or_with_a_malformed_item_is_refused_before .AsNoTracking() .Where(row => row.SessionId != TestChangeSetTargets.ContentModelSession) .ToListAsync())); - Assert.Empty(await agent.QueryAsync(context => context.Jobs.AsNoTracking().ToListAsync())); + Assert.Empty(await agent.QueryAsync(context => context.Jobs.AsNoTracking().Where(row => row.Type == BulkEntryUpdates.Name).ToListAsync())); } [Theory] From cfa67aac70f5ac7e670426ee5ae2115b5da65809 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 25 Oct 2026 20:30:53 -0400 Subject: [PATCH 5/7] Count bulk failures per item, refuse a submit while the job fills the set, and name no unsaved set in the cap refusal --- src/FluentCMS.Core/Agents/AgentPermissions.cs | 28 ++++ .../Agents/IAgentPermissions.cs | 3 + .../Builtin/Jobs/BulkEntryUpdates.cs | 24 +++- src/FluentCMS.Core/Builtin/Tools/BulkTools.cs | 2 +- .../ChangeSets/ChangeSetErrorCodes.cs | 1 + src/FluentCMS.Core/Checks/ChangeSetChecks.cs | 12 ++ .../Mcp/BulkToolsTests.cs | 135 +++++++++++++++--- 7 files changed, 177 insertions(+), 28 deletions(-) diff --git a/src/FluentCMS.Core/Agents/AgentPermissions.cs b/src/FluentCMS.Core/Agents/AgentPermissions.cs index 1f1ee952a..eef42ebdf 100644 --- a/src/FluentCMS.Core/Agents/AgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/AgentPermissions.cs @@ -173,6 +173,34 @@ await RecordAsync( + $"{cap} (items per change set). Submit this change set and add the rest to a new one."); } + public async Task CheckNewChangeSetSizeAsync(int items, CancellationToken cancellationToken = default) + { + if (currentInitiator.Current is SystemInitiator system) + { + return SystemRefused(system, "open a change set"); + } + + var agent = await AgentAsync(cancellationToken); + if (!agent.IsSuccess) + { + return Result.Failure(agent.Errors); + } + + if (agent.Value is not { } credential || items <= credential.Caps.ItemsPerChangeSet) + { + return Result.Success(); + } + + await RecordAsync( + new NewAuditEvent("agent.items_per_change_set_exceeded") { SubjectType = "agent_credential", SubjectId = credential.Id }, + cancellationToken); + + return Result.Failure( + AgentPermissionErrorCodes.CapExceeded, + $"{items} items are more than the credential '{credential.DisplayName}' allows in one change set: {credential.Caps.ItemsPerChangeSet} " + + "(items per change set). Split them over several calls of at most that many."); + } + public async Task CheckContentAsync( string contentType, AgentOperation operation, diff --git a/src/FluentCMS.Core/Agents/IAgentPermissions.cs b/src/FluentCMS.Core/Agents/IAgentPermissions.cs index 45f4af8a1..5dc41a070 100644 --- a/src/FluentCMS.Core/Agents/IAgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/IAgentPermissions.cs @@ -24,6 +24,9 @@ public interface IAgentPermissions // or ahead of a write with the number of operations it will add. Task CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default); + // Ahead of opening a set for that many items at once, as a bulk write does, before the set exists. + Task CheckNewChangeSetSizeAsync(int items, CancellationToken cancellationToken = default); + // The operation on items of the content type (MCP-05). A write names the fields it changes, which a field list in the scope can // narrow; a read names none, so an agent reads whole entries of a type in its scope. Task CheckContentAsync( diff --git a/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs index 78d358c9b..01ec644f1 100644 --- a/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs +++ b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs @@ -16,7 +16,7 @@ public sealed class BulkEntryUpdates : IJobHandler { public const string Name = "bulk.entry_updates"; - private const int MaxErrorsListed = 20; + private const int MaxFailuresListed = 20; public string Type => Name; @@ -65,11 +65,14 @@ public async Task RunAsync(JobContext context, CancellationToken cancell for (var index = 0; index < total; index++) { var item = request.Items[index]; - if (!applied.Contains(item.Id)) + if (applied.Add(item.Id)) { var updated = await ApplyAsync(services, request.ChangeSetId, item, cancellationToken); - failures.AddRange(updated.Errors.Select(error => new Error(error.Code, $"items[{index}] {item.Id}: {error.Message}"))); - applied.Add(item.Id); + if (!updated.IsSuccess && !await AppliedMeanwhileAsync(changeSets, request.ChangeSetId, item.Id, cancellationToken)) + { + // One entry per item, however many problems it has, so the counts and the list are of items. + failures.Add(new Error(updated.Errors[0].Code, $"items[{index}] {item.Id}: {string.Join(" ", updated.Errors.Select(error => error.Message))}")); + } } var message = $"Updated {index + 1 - failures.Count} of {total}" + (failures.Count > 0 ? $"; {failures.Count} failed." : "."); @@ -85,15 +88,22 @@ public async Task RunAsync(JobContext context, CancellationToken cancell return Result.Success(); } - var listed = failures.Take(MaxErrorsListed).ToList(); - if (failures.Count > MaxErrorsListed) + var listed = failures.Take(MaxFailuresListed).ToList(); + if (failures.Count > MaxFailuresListed) { - listed.Add(new Error("bulk_items_failed", $"{failures.Count - MaxErrorsListed} more items failed.")); + listed.Add(new Error("bulk_items_failed", $"{failures.Count - MaxFailuresListed} more items failed.")); } return Result.Failure(listed); } + // A worker that lost its lease can commit an item after this run read the set; the item is in the set, so it has not failed. + private static async Task AppliedMeanwhileAsync(IChangeSetDirectory changeSets, Guid changeSetId, Guid itemId, CancellationToken cancellationToken) + { + var set = await changeSets.GetAsync(changeSetId, cancellationToken); + return set.IsSuccess && set.Value.Operations.Any(operation => operation.ItemId == itemId); + } + // The update and its operation commit together or not at all; leaving the unit of work uncommitted undoes both. private static async Task ApplyAsync(IServiceProvider services, Guid changeSetId, BulkEntryUpdate item, CancellationToken cancellationToken) { diff --git a/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs b/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs index 3338a91bd..4efc63a5f 100644 --- a/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/BulkTools.cs @@ -87,7 +87,7 @@ private static async Task UpdateAsync(ToolInvocation call, Cancellat foreach (var check in new Func>[] { () => permissions.CheckNewChangeSetAsync(cancellationToken), - () => permissions.CheckChangeSetSizeAsync(planned.Value, adding: items.Count, cancellationToken), + () => permissions.CheckNewChangeSetSizeAsync(items.Count, cancellationToken), }) { var allowed = await check(); diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs index ef6803f72..5205fa362 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSetErrorCodes.cs @@ -13,6 +13,7 @@ public static class ChangeSetErrorCodes public const string SourceBlank = "source_blank"; public const string SessionIdInvalid = "session_id_invalid"; public const string NotOpen = "change_set_not_open"; + public const string Busy = "change_set_busy"; public const string OperationsMissing = "operations_missing"; public const string TransitionNotAllowed = "transition_not_allowed"; public const string RejectionReasonMissing = "rejection_reason_missing"; diff --git a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs index 9a83da529..e7e344b15 100644 --- a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs +++ b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs @@ -69,6 +69,18 @@ public async Task> SubmitAsync(Guid changeSetId, Can : Result.Failure(abandoned.IsSuccess ? recovered.Errors : abandoned.Errors); } + // A job still adding items to the set, such as a bulk update, ends first, so a reviewer never gets part of its work. The job + // is enqueued with the set, so an open set cannot be read before it. + if (read.Value.State == ChangeSetState.Open + && MaySubmit(read.Value) + && (await jobs.ListForSubjectAsync(new JobSubject(SubjectType, changeSetId), cancellationToken)) + .FirstOrDefault(job => job.Type != JobType && job.State is JobState.Queued or JobState.Running) is { } busy) + { + return Result.Failure( + ChangeSetErrorCodes.Busy, + $"Change set {changeSetId} is still being filled by job {busy.Id} ({busy.Type}). Follow it with get_job and submit the set once it has ended."); + } + var inline = read.Value.Operations.Count <= InlineItems && !await AnyJudgedAsync(cancellationToken); // Opened before the set moves, so a host that cannot run the engine says so instead of leaving every set to its job. diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs index ed9dd7eab..546a9e49d 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs @@ -5,9 +5,11 @@ using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Content; using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; using FluentCMS.Core.Schema; using FluentCMS.Core.Tools; using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -37,10 +39,13 @@ public async Task A_bulk_update_of_180_entities_returns_the_change_set_at_once_a }); var changeSetId = (string)started["changeSet"]!["id"]!; var jobId = (string)started["job"]!["id"]!; - var finished = await FinishedAsync(agent, jobId); + var seen = new List(); + var finished = await FinishedAsync(agent, jobId, seen); Assert.Equal("open", (string?)started["changeSet"]!["state"]); + Assert.Equal(("queued", 0), ((string?)started["job"]!["state"], (int)started["job"]!["progress"]!["done"]!)); Assert.Equal(changeSetId, (string?)started["job"]!["subject"]!["id"]); + Assert.Equal(seen.Order(), seen); Assert.Equal("succeeded", (string?)finished["state"]); Assert.Equal((180, 180), ((int)finished["progress"]!["done"]!, (int)finished["progress"]!["total"]!)); Assert.Equal("Updated 180 of 180.", (string?)finished["progress"]!["message"]); @@ -60,11 +65,13 @@ public async Task A_bulk_update_of_180_entities_returns_the_change_set_at_once_a public async Task An_item_that_fails_is_listed_by_its_id_and_the_rest_go_into_the_set(string provider) { await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); - var products = await CreateProductsAsync(agent, 3); + var products = await CreateProductsAsync(agent, 4); + // Plain values where the entity's fields take facts: one item with a problem in every field. + var withoutFacts = new Dictionary { ["id"] = products[2].ToString(), ["base_version"] = 1, ["fields"] = new { name = "Widget", price = 3 } }; var started = await agent.CallAsync("bulk_update_entries", new() { - ["items"] = new[] { Item(products[0], 1, 1m), Item(products[1], 5, 2m), Item(products[2], 1, 3m) }, + ["items"] = new[] { Item(products[0], 1, 1m), Item(products[1], 5, 2m), withoutFacts, Item(products[3], 1, 4m) }, ["rationale"] = "Sync prices.", }); var finished = await FinishedAsync(agent, (string)started["job"]!["id"]!); @@ -72,15 +79,18 @@ public async Task An_item_that_fails_is_listed_by_its_id_and_the_rest_go_into_th Assert.Equal("failed", (string?)finished["state"]); Assert.Equal(1, (int)finished["attempts"]!); Assert.Contains($"items[1] {products[1]}", (string?)finished["lastError"]); - Assert.Equal("Updated 2 of 3; 1 failed.", (string?)finished["progress"]!["message"]); + Assert.Contains($"items[2] {products[2]}", (string?)finished["lastError"]); + Assert.Equal("Updated 2 of 4; 2 failed.", (string?)finished["progress"]!["message"]); var set = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse((string)started["changeSet"]!["id"]!))); - Assert.Equal([products[0], products[2]], set.Operations.Select(operation => operation.ItemId)); + Assert.Equal([products[0], products[3]], set.Operations.Select(operation => operation.ItemId)); } - [Fact] - public async Task A_list_past_the_cap_or_with_a_malformed_item_is_refused_before_a_set_is_opened() + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_list_past_the_cap_or_with_a_malformed_item_is_refused_before_a_set_is_opened(string provider) { - await using var agent = await ConnectedAgent.StartAsync(DatabaseProviders.Sqlite, itemsPerChangeSet: 2, settings: FastQueue); + await using var agent = await ConnectedAgent.StartAsync(provider, itemsPerChangeSet: 2, settings: FastQueue); var products = await CreateProductsAsync(agent, 3); var overCap = await agent.FailAsync("bulk_update_entries", new() @@ -95,6 +105,12 @@ public async Task A_list_past_the_cap_or_with_a_malformed_item_is_refused_before }); Assert.Equal(AgentPermissionErrorCodes.CapExceeded, (string?)overCap["errors"]![0]!["code"]); + Assert.StartsWith("3 items are more than", (string?)overCap["errors"]![0]!["message"], StringComparison.Ordinal); + var refusal = Assert.Single(await agent.QueryAsync(context => context.AuditEvents + .AsNoTracking() + .Where(row => row.Operation == "agent.items_per_change_set_exceeded") + .ToListAsync())); + Assert.Equal(("agent_credential", agent.Credential.Id), (refusal.SubjectType, refusal.SubjectId)); Assert.Equal( [(ToolErrorCodes.ArgumentInvalid, "items[1]"), (ToolErrorCodes.ArgumentMissing, "items[1]"), (ToolErrorCodes.ArgumentInvalid, "items[2]")], malformed["errors"]!.AsArray().Select(error => ((string)error!["code"]!, ((string)error["message"]!).Split(':')[0]))); @@ -143,22 +159,82 @@ public async Task A_job_run_again_after_stopping_partway_adds_each_item_once(str } } - [Fact] - public async Task Get_job_shows_an_agent_its_own_jobs_only() + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_job_that_fails_partway_is_retried_by_the_worker_and_adds_each_item_once(string provider) { - await using var agent = await ConnectedAgent.StartAsync(DatabaseProviders.Sqlite, settings: FastQueue); - var products = await CreateProductsAsync(agent, 1); - var started = await agent.CallAsync("bulk_update_entries", new() { ["items"] = new[] { Item(products[0], 1, 1m) }, ["rationale"] = "One price." }); - var someoneElses = await agent.AsPersonAsync(async services => + var failed = 0; + var handler = new SteppedBulkUpdates((context, run) => Interlocked.Exchange(ref failed, 1) == 0 + ? run(new JobContext(context.Job, context.Services, (progress, token) => progress.Done == 2 + ? throw new InvalidOperationException("The host stopped after the second item.") + : context.ReportProgressAsync(progress.Done, progress.Total, progress.Message, token))) + : run(context)); + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue, configure: handler.Register); + var products = await CreateProductsAsync(agent, 3); + + var started = await agent.CallAsync("bulk_update_entries", new() { - await using var work = await services.GetRequiredService().BeginAsync(); - var job = await services.GetRequiredService().EnqueueAsync(new NewJob("test.someone-else", default) { RunAt = DateTimeOffset.UtcNow.AddDays(1) }); - await work.CommitAsync(); - return Core.Results.Result.Success(job); + ["items"] = products.Select(id => Item(id, 1, 5m)).ToArray(), + ["rationale"] = "Sync prices.", }); + var finished = await FinishedAsync(agent, (string)started["job"]!["id"]!); + + Assert.Equal(("succeeded", 2), ((string?)finished["state"], (int)finished["attempts"]!)); + Assert.Equal("Updated 3 of 3.", (string?)finished["progress"]!["message"]); + var set = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse((string)started["changeSet"]!["id"]!))); + Assert.Equal(products, set.Operations.Select(operation => operation.ItemId)); + foreach (var id in products) + { + Assert.Equal(2, (await agent.AsPersonAsync(entries => entries.GetAsync(id))).Latest.Version); + } + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_is_not_submitted_while_its_bulk_job_is_still_filling_it(string provider) + { + var release = new TaskCompletionSource(); + var handler = new SteppedBulkUpdates(async (context, run) => + { + await release.Task; + return await run(context); + }); + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue, configure: handler.Register); + var products = await CreateProductsAsync(agent, 2); + var started = await agent.CallAsync("bulk_update_entries", new() + { + ["items"] = products.Select(id => Item(id, 1, 5m)).ToArray(), + ["rationale"] = "Sync prices.", + }); + var changeSetId = (string)started["changeSet"]!["id"]!; + + var early = await agent.FailAsync("submit_change_set", new() { ["id"] = changeSetId }); + release.SetResult(); + var finished = await FinishedAsync(agent, (string)started["job"]!["id"]!); + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = changeSetId }); + + Assert.Equal(ChangeSetErrorCodes.Busy, (string?)early["errors"]![0]!["code"]); + Assert.Equal("succeeded", (string?)finished["state"]); + Assert.NotEqual("open", (string?)submitted["changeSet"]!["state"]); + var set = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse(changeSetId))); + Assert.Equal(products, set.Operations.Select(operation => operation.ItemId)); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Get_job_shows_an_agent_its_own_jobs_only(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); + await using var another = await agent.AnotherAgentAsync("another-agent"); + var products = await CreateProductsAsync(agent, 2); + var started = await agent.CallAsync("bulk_update_entries", new() { ["items"] = new[] { Item(products[0], 1, 1m) }, ["rationale"] = "One price." }); + var anothers = await another.CallAsync("bulk_update_entries", new() { ["items"] = new[] { Item(products[1], 1, 2m) }, ["rationale"] = "Another price." }); var own = await agent.CallAsync("get_job", new() { ["id"] = (string)started["job"]!["id"]! }); - var other = await agent.FailAsync("get_job", new() { ["id"] = someoneElses.Id.ToString() }); + var other = await agent.FailAsync("get_job", new() { ["id"] = (string)anothers["job"]!["id"]! }); Assert.Equal(BulkEntryUpdates.Name, (string?)own["type"]); Assert.Equal(JobErrorCodes.JobNotFound, (string?)other["errors"]![0]!["code"]); @@ -197,13 +273,15 @@ private static string Fact(string value) return $$"""{"value": {{value}}, "source": null, "owner": null, "validFrom": null, "validUntil": null, "lastVerified": null}"""; } - private static async Task FinishedAsync(ConnectedAgent agent, string jobId) + // Each read's progress goes into seen, when given. + private static async Task FinishedAsync(ConnectedAgent agent, string jobId, List? seen = null) { // The job takes a few seconds for 180 items on its own; a loaded test machine can take far longer. var deadline = DateTime.UtcNow.AddMinutes(3); while (true) { var job = await agent.CallAsync("get_job", new() { ["id"] = jobId }); + seen?.Add((int)job["progress"]!["done"]!); if ((string?)job["state"] is "succeeded" or "failed" or "dead" || DateTime.UtcNow > deadline) { return job; @@ -212,4 +290,21 @@ private static async Task FinishedAsync(ConnectedAgent agent, string await Task.Delay(100); } } + + // The product's bulk handler with a test's step around each attempt, run by the real worker. It is registered before the + // product's handlers, and the worker takes the first handler of a job's type. + private sealed class SteppedBulkUpdates(Func>, Task> attempt) : IJobHandler + { + public string Type => BulkEntryUpdates.Name; + + public Task RunAsync(JobContext context, CancellationToken cancellationToken) + { + return attempt(context, inner => new BulkEntryUpdates().RunAsync(inner, cancellationToken)); + } + + public void Register(WebApplicationBuilder builder) + { + builder.Services.AddSingleton(this); + } + } } From 4ab0550723935273bb0414b25ec8cdb3930edafd Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 25 Oct 2026 20:37:12 -0400 Subject: [PATCH 6/7] Say a set is not submitted while its bulk job fills it --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2faeba27..869db93be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,7 @@ The browser tests drive the admin in headless Chromium through Playwright: keybo To run the host, `dotnet run --project src/FluentCMS.Host` starts the web host on `http://localhost:5000` with a SQLite database in `fluentcms.db`, and `dotnet run --project src/FluentCMS.Host -- mcp` starts the stdio mode. Configuration comes from environment variables such as `Database__ConnectionString`, `Database__Provider` (`sqlite` or `postgresql`, inferred from the connection string when omitted), `JobQueue__WorkerCount`, `Content__DeletedRetention` (how long a deleted entry can be restored, `30.00:00:00` by default), `Entities__VerifyWithin` (how long a fact stays verified before it is listed as stale, `180.00:00:00` by default), `Delivery__PreviewTokenLifetime` (how long a preview token works unless it is revoked or its change set ends, `7.00:00:00` by default), `Delivery__RateLimit__PermitLimit` and `Delivery__RateLimit__Window` (how many delivery API requests one client address may make per window, 600 a minute by default), `ReverseProxy__KnownProxies__0` and `ReverseProxy__KnownNetworks__0` (the reverse proxies, by address such as `10.0.0.5` or network such as `10.0.0.0/8`, whose `X-Forwarded-For` names the client that rate limits count, read one hop deep; with none, the header is ignored), `Media__Storage` (the storage new uploads go to, `local` by default), `Media__LocalDisk__Root` (where the local storage keeps bytes, `media` under the content root by default) and `Media__MaxBytes` (the largest upload, 50 MB by default; over MCP on HTTP, the server's 30 MB request limit holds an upload to about 22 MB, since base64 grows a file by a third), and from an optional `appsettings.json` next to the binary. -Agents reach the MCP server (ADR-0007) through either mode. The web host serves streamable HTTP at `/mcp` in stateless mode: each request stands alone, no session id is issued, and a GET answers 405 because the server never opens a stream to the client. `fluentcms mcp` serves the same server over standard input and output to an agent that starts it as a local process, for example with `dotnet run --project src/FluentCMS.Host -- mcp` as its command. It reads the same settings as the web host, so pass it the web host's `Database__ConnectionString` and `DataProtection__KeysDirectory` to work on the same database and key ring. Relative paths resolve against the process's working directory, so give an agent absolute ones. Standard output carries only protocol messages, log lines go to standard error, and the process stops when its standard input closes. The stdio mode runs with the local user's database access, so it is meant for development and single-user setups. Clients on protocol revision 2026-07-28 connect with `server/discover`, earlier ones with `initialize`. Agents read content types, the schema document and change sets with the read-only tools, and propose changes with `open_change_set`, `create_content_type`, `revise_content_type` and `submit_change_set`. A write stores an unpublished version and its operation in the agent's change set together, and the version is not published until a person approves and publishes the set; no tool approves, rejects or publishes. Agents follow review with the read-only desk tools: `get_desk_counts`, `list_desk_queue` and `get_change_set_review`, which see only the change sets the agent opened. `bulk_update_entries` opens a change set for many entry or entity updates and returns its id and a job id at once; the job applies the updates in the background (see Background jobs), and `get_job` shows its progress and any item that failed. +Agents reach the MCP server (ADR-0007) through either mode. The web host serves streamable HTTP at `/mcp` in stateless mode: each request stands alone, no session id is issued, and a GET answers 405 because the server never opens a stream to the client. `fluentcms mcp` serves the same server over standard input and output to an agent that starts it as a local process, for example with `dotnet run --project src/FluentCMS.Host -- mcp` as its command. It reads the same settings as the web host, so pass it the web host's `Database__ConnectionString` and `DataProtection__KeysDirectory` to work on the same database and key ring. Relative paths resolve against the process's working directory, so give an agent absolute ones. Standard output carries only protocol messages, log lines go to standard error, and the process stops when its standard input closes. The stdio mode runs with the local user's database access, so it is meant for development and single-user setups. Clients on protocol revision 2026-07-28 connect with `server/discover`, earlier ones with `initialize`. Agents read content types, the schema document and change sets with the read-only tools, and propose changes with `open_change_set`, `create_content_type`, `revise_content_type` and `submit_change_set`. A write stores an unpublished version and its operation in the agent's change set together, and the version is not published until a person approves and publishes the set; no tool approves, rejects or publishes. Agents follow review with the read-only desk tools: `get_desk_counts`, `list_desk_queue` and `get_change_set_review`, which see only the change sets the agent opened. `bulk_update_entries` opens a change set for many entry or entity updates and returns its id and a job id at once; the job applies the updates in the background (see Background jobs), and `get_job` shows its progress and any item that failed. The set can't be submitted while the job is still filling it. Every MCP call runs as an agent and needs that agent's key, issued on `/agents/new`. Over HTTP the key goes in the `Authorization: Bearer ` header of each request; `/mcp` reads no other credential, so a signed-in person's admin cookie is ignored there. Over stdio the key goes in the `FLUENTCMS_AGENT_KEY` environment variable of the process, which an agent's MCP configuration can set; it is never read from a command-line argument, which other users can see in process listings. Both transports check the key on every request or message, so an expired or revoked key is refused from its next call on, with a message that says why. Never put a key in a URL: the server ignores one there, but ASP.NET Core logs request URLs once `Logging__LogLevel__Microsoft.AspNetCore` is below its shipped `Warning`. Every tool call is recorded in the `ToolCalls` table with the agent, the tool, the outcome and how long it took. An agent can add the model it runs on and the task it works on as strings in the call's `_meta`, under `fluentcms/model` and `fluentcms/task`, and the record keeps both. Arguments and results are not recorded; the audit log records every change a tool makes. From 3e0e1dc2036c1123c20bb371ef32f887bf1b2160 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Sun, 25 Oct 2026 20:58:14 -0400 Subject: [PATCH 7/7] Put the failed count first in a bulk job's error and keep each reason short --- .../Builtin/Jobs/BulkEntryUpdates.cs | 19 +++++++++------ src/FluentCMS.Core/Checks/ChangeSetChecks.cs | 2 +- .../Mcp/BulkToolsTests.cs | 23 +++++++++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs index 01ec644f1..6321fe103 100644 --- a/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs +++ b/src/FluentCMS.Core/Builtin/Jobs/BulkEntryUpdates.cs @@ -18,6 +18,9 @@ public sealed class BulkEntryUpdates : IJobHandler private const int MaxFailuresListed = 20; + // Short enough that the count and every listed item fit in the job's last error, which the queue keeps to 4000 characters. + private const int MaxReasonLength = 100; + public string Type => Name; public static JsonElement Payload(Guid changeSetId, IReadOnlyList items) @@ -71,7 +74,8 @@ public async Task RunAsync(JobContext context, CancellationToken cancell if (!updated.IsSuccess && !await AppliedMeanwhileAsync(changeSets, request.ChangeSetId, item.Id, cancellationToken)) { // One entry per item, however many problems it has, so the counts and the list are of items. - failures.Add(new Error(updated.Errors[0].Code, $"items[{index}] {item.Id}: {string.Join(" ", updated.Errors.Select(error => error.Message))}")); + var reason = string.Join(" ", updated.Errors.Select(error => error.Message)); + failures.Add(new Error(updated.Errors[0].Code, $"items[{index}] {item.Id}: {Shorten(reason)}")); } } @@ -88,13 +92,14 @@ public async Task RunAsync(JobContext context, CancellationToken cancell return Result.Success(); } - var listed = failures.Take(MaxFailuresListed).ToList(); - if (failures.Count > MaxFailuresListed) - { - listed.Add(new Error("bulk_items_failed", $"{failures.Count - MaxFailuresListed} more items failed.")); - } + // The count first, so it is kept whatever follows. + var counted = $"{failures.Count} of {total} items failed" + (failures.Count > MaxFailuresListed ? $"; the first {MaxFailuresListed} are listed." : "."); + return Result.Failure([new Error("bulk_items_failed", counted), .. failures.Take(MaxFailuresListed)]); + } - return Result.Failure(listed); + private static string Shorten(string reason) + { + return reason.Length <= MaxReasonLength ? reason : string.Concat(reason.AsSpan(0, MaxReasonLength - 1), "…"); } // A worker that lost its lease can commit an item after this run read the set; the item is in the set, so it has not failed. diff --git a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs index e7e344b15..a774998b2 100644 --- a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs +++ b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs @@ -78,7 +78,7 @@ public async Task> SubmitAsync(Guid changeSetId, Can { return Result.Failure( ChangeSetErrorCodes.Busy, - $"Change set {changeSetId} is still being filled by job {busy.Id} ({busy.Type}). Follow it with get_job and submit the set once it has ended."); + $"Change set {changeSetId} is still being filled by job {busy.Id} ({busy.Type}); submit it once the job has ended."); } var inline = read.Value.Operations.Count <= InlineItems && !await AnyJudgedAsync(cancellationToken); diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs index 546a9e49d..d3a3a3a4b 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/BulkToolsTests.cs @@ -78,6 +78,7 @@ public async Task An_item_that_fails_is_listed_by_its_id_and_the_rest_go_into_th Assert.Equal("failed", (string?)finished["state"]); Assert.Equal(1, (int)finished["attempts"]!); + Assert.StartsWith("bulk_items_failed: 2 of 4 items failed. ", (string?)finished["lastError"], StringComparison.Ordinal); Assert.Contains($"items[1] {products[1]}", (string?)finished["lastError"]); Assert.Contains($"items[2] {products[2]}", (string?)finished["lastError"]); Assert.Equal("Updated 2 of 4; 2 failed.", (string?)finished["progress"]!["message"]); @@ -85,6 +86,28 @@ public async Task An_item_that_fails_is_listed_by_its_id_and_the_rest_go_into_th Assert.Equal([products[0], products[3]], set.Operations.Select(operation => operation.ItemId)); } + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Many_failed_items_keep_their_count_and_the_first_twenty_in_the_jobs_last_error(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); + var products = await CreateProductsAsync(agent, 30); + + var started = await agent.CallAsync("bulk_update_entries", new() + { + ["items"] = products.Select(id => new Dictionary { ["id"] = id.ToString(), ["base_version"] = 1, ["fields"] = new { name = "Widget", price = 3 } }).ToArray(), + ["rationale"] = "Sync prices.", + }); + var finished = await FinishedAsync(agent, (string)started["job"]!["id"]!); + + var lastError = (string)finished["lastError"]!; + Assert.StartsWith("bulk_items_failed: 30 of 30 items failed; the first 20 are listed. ", lastError, StringComparison.Ordinal); + Assert.Contains($"items[19] {products[19]}", lastError); + Assert.DoesNotContain("items[20]", lastError); + Assert.Equal("Updated 0 of 30; 30 failed.", (string?)finished["progress"]!["message"]); + } + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData]