From cfb94f3d411c9c28d749c5a38a236ea085d1b3fd Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:08:00 -0400 Subject: [PATCH 1/5] Run the checks on every submitted change set --- CONTRIBUTING.md | 2 + .../Builtin/Checks/ModelJudgement.cs | 2 +- .../Builtin/Jobs/CheckRunJob.cs | 56 + .../Builtin/Tools/ChangeSetTools.cs | 43 +- .../Builtin/Tools/CheckTools.cs | 46 +- src/FluentCMS.Core/Builtin/Tools/ToolJson.cs | 61 + .../Checks/ChangeSetCheckRun.cs | 70 + src/FluentCMS.Core/Checks/ChangeSetChecks.cs | 343 +++++ src/FluentCMS.Core/Checks/CheckInput.cs | 3 +- src/FluentCMS.Core/Checks/CheckResult.cs | 10 + src/FluentCMS.Core/Checks/CheckRunner.cs | 16 +- .../CoreServiceCollectionExtensions.cs | 3 + .../Identity/ActingInitiator.cs | 46 + src/FluentCMS.Host/FluentCmsHost.cs | 12 +- src/FluentCMS.Host/Jobs/JobQueueWorker.cs | 3 +- src/FluentCMS.Host/Jobs/JobRun.cs | 16 - .../Checks/CheckRunConfiguration.cs | 53 + .../Checks/CheckRunRow.cs | 74 + .../Checks/CheckRuns.cs | 118 ++ ...frastructureServiceCollectionExtensions.cs | 1 + .../Persistence/FluentCmsDbContext.cs | 6 + .../20261019035255_CheckRuns.Designer.cs | 1346 +++++++++++++++++ .../PostgreSql/20261019035255_CheckRuns.cs | 100 ++ ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 157 ++ .../20261019035242_CheckRuns.Designer.cs | 1336 ++++++++++++++++ .../Sqlite/20261019035242_CheckRuns.cs | 100 ++ .../SqliteFluentCmsDbContextModelSnapshot.cs | 157 ++ .../Checks/CheckDirectoryTests.cs | 3 +- .../Mcp/ChangeSetChecksTests.cs | 289 ++++ .../Mcp/ConnectedAgent.cs | 25 +- .../Mcp/McpAgents.cs | 10 +- .../Mcp/WriteToolsTests.cs | 5 +- .../TestServices.cs | 7 + .../Checks/CheckRunJobTests.cs | 147 ++ .../Checks/CheckRunnerTests.cs | 12 + .../FluentCMS.Tests/Tools/McpCoverageTests.cs | 2 +- 36 files changed, 4614 insertions(+), 66 deletions(-) create mode 100644 src/FluentCMS.Core/Builtin/Jobs/CheckRunJob.cs create mode 100644 src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs create mode 100644 src/FluentCMS.Core/Checks/ChangeSetChecks.cs create mode 100644 src/FluentCMS.Core/Identity/ActingInitiator.cs delete mode 100644 src/FluentCMS.Host/Jobs/JobRun.cs create mode 100644 src/FluentCMS.Infrastructure/Checks/CheckRunConfiguration.cs create mode 100644 src/FluentCMS.Infrastructure/Checks/CheckRunRow.cs create mode 100644 src/FluentCMS.Infrastructure/Checks/CheckRuns.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.cs create mode 100644 tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs create mode 100644 tests/FluentCMS.Tests/Checks/CheckRunJobTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8dc66c45e..844d93bb8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,6 +135,8 @@ An LLM-judged type asks a model only through `IModelGateway`, which it reads fro A check is a versioned definition under a slug: a check type and its settings, a description of what it enforces, a severity (block or warn), an optional scope of content types and fields, and test examples, at least one it must pass and one it must fail. `ICheckDirectory` activates a version only after running its examples, and names each example that does not get its verdict (CHK-02). A revision leaves the active version in place until the new one is activated, and only a person activates or deactivates a check. `ICheckRunner` runs the active checks that apply to a piece of content and gives each one's status: pass, warn for a failing check of warn severity, fail, or needs review. Agents run it on a draft with the `check_draft` tool before they propose it (CHK-04). +A submitted change set is checked as the check engine (`SystemInitiator.Checks`, ADR-0012), never as the agent: submit it through `IChangeSetChecks.SubmitAsync`, not by moving it to checks running yourself. A set of up to 50 operations that no active LLM-judged check reads is checked on the request that submits it; any other set is checked by a `checks.run` job, which reports its progress (PERF-02). Every entry and entity the set creates or updates is checked at the version it proposes, and an LLM-judged check charges its model call to the agent that opened the set (SEC-04). An item the engine cannot read, such as an entry deleted since, is a problem that fails the run, as is a job whose last attempt throws, so a set never stays in checks running. Each run belongs to its submission's job, and a run of an earlier submission changes nothing. The run, each item's results with their findings and model calls, and the move it decides (checks failed when a check fails, otherwise awaiting review) commit together, numbered per set, with the audit events `change_set.checked` and `check.judged` (CHK-03, CHK-05). Agents read the latest run and its job with the `get_change_set_checks` tool. + ## Code style `.editorconfig` is the source of truth: UTF-8, LF line endings, four spaces in C#, two in JSON and YAML, file-scoped namespaces. Nullable reference types and implicit usings are enabled project-wide. Prefer clear code over clever code. Comments explain why, not what. Do not add XML documentation comments unless an issue asks for them. diff --git a/src/FluentCMS.Core/Builtin/Checks/ModelJudgement.cs b/src/FluentCMS.Core/Builtin/Checks/ModelJudgement.cs index a912da9fd..79c1fef2f 100644 --- a/src/FluentCMS.Core/Builtin/Checks/ModelJudgement.cs +++ b/src/FluentCMS.Core/Builtin/Checks/ModelJudgement.cs @@ -72,7 +72,7 @@ public static async Task JudgeAsync( data.AddRange(settings); var asked = await gateway.AskAsync( - new ModelRequest(Profile, $"check:{checkType}", task + "\n\n" + AnswerRules, data) { AnswerSchema = AnswerSchema }, + new ModelRequest(Profile, $"check:{checkType}", task + "\n\n" + AnswerRules, data) { AnswerSchema = AnswerSchema, ChargedTo = input.ChargedTo }, cancellationToken); if (!asked.IsSuccess) { diff --git a/src/FluentCMS.Core/Builtin/Jobs/CheckRunJob.cs b/src/FluentCMS.Core/Builtin/Jobs/CheckRunJob.cs new file mode 100644 index 000000000..27e522290 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Jobs/CheckRunJob.cs @@ -0,0 +1,56 @@ +using System.Text.Json; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Jobs; + +// The check run of a submitted change set (CHK-03), as the check engine whoever submitted the set (ADR-0012). A run is safe to +// repeat: a set the checks have already moved on is left as it is. The payload only names the set; whether to run, and where the +// set goes, come from what is stored. +internal sealed class CheckRunJob : IJobHandler +{ + public string Type => ChangeSetChecks.JobType; + + public SystemInitiator RunAs => SystemInitiator.Checks; + + public async Task RunAsync(JobContext context, CancellationToken cancellationToken) + { + var payload = context.Job.Payload; + if (payload.ValueKind != JsonValueKind.Object + || !payload.TryGetProperty("changeSetId", out var id) + || id.ValueKind != JsonValueKind.String + || !id.TryGetGuid(out var changeSetId)) + { + return Result.Failure("payload_invalid", "A check run names the change set it checks as changeSetId."); + } + + var checks = context.Services.GetRequiredService(); + try + { + var ran = await checks.RunAsync( + changeSetId, + context.Job.Id, + CheckRunMode.Job, + (done, total) => context.ReportProgressAsync(done, total, $"Checked {done} of {total} items.", cancellationToken), + cancellationToken); + + // A write that met the run's own can be got past by another attempt on what is stored then. + if (!ran.IsSuccess && ran.Errors.Any(error => error.Code == ChangeSetErrorCodes.Conflict)) + { + throw new InvalidOperationException(ran.Errors[0].Message); + } + + return ran.IsSuccess ? Result.Success() : Result.Failure(ran.Errors); + } + catch (Exception exception) when (exception is not OperationCanceledException && context.Job.Attempts >= context.Job.MaxAttempts) + { + // The last attempt fails the set, so the agent can reopen it, rather than leave it in checks running for good. + var abandoned = await checks.AbandonAsync(changeSetId, context.Job.Id, CheckRunMode.Job, cancellationToken); + return Result.Failure(abandoned.IsSuccess ? [new Error("check_run_abandoned", exception.Message)] : abandoned.Errors); + } + } +} diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs index 0bb5dd987..7bce4fb20 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs @@ -1,5 +1,6 @@ using System.Text.Json.Nodes; using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; using FluentCMS.Core.Results; using FluentCMS.Core.Tools; using Microsoft.Extensions.DependencyInjection; @@ -47,12 +48,14 @@ public sealed class ChangeSetTools : IToolProvider new AgentTool( "submit_change_set", "Submit a change set for checks and review", - "Submits one of your open change sets once it holds every operation it needs: checks run on it, and a person reviews " - + "it before anything is published. A submitted set takes no more operations. An agent never approves or publishes " - + "a set; if checks fail, reopen it with reopen_change_set, fix it and submit it again.", + "Submits one of your open change sets once it holds every operation it needs: the site's checks run on it, and a person " + + "reviews it before anything is published. A small set is checked at once and the answer holds its check results; a " + + "larger one, or one an LLM-judged check reads, is checked in the background, so follow it with get_change_set_checks. " + + "A submitted set takes no more operations. An agent never approves or publishes a set; if checks fail, reopen it with " + + "reopen_change_set, fix what they found and submit it again.", ToolSchemas.Object(ToolSchemas.Uuid("id", "The id of your open change set.")), readOnly: false, - (call, cancellationToken) => MoveAsync(call, ChangeSetState.ChecksRunning, cancellationToken)), + SubmitAsync), new AgentTool( "reopen_change_set", "Reopen a change set after failed checks", @@ -81,6 +84,38 @@ private static async Task OpenAsync(ToolInvocation call, Cancellatio }); } + private static async Task SubmitAsync(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 submitted = await call.Services.GetRequiredService().SubmitAsync(id!.Value, cancellationToken); + if (!submitted.IsSuccess) + { + return ToolResult.Failure(submitted.Errors, Hint(Result.Failure(submitted.Errors))); + } + + var submission = submitted.Value; + var set = submission.ChangeSet; + return ToolResult.Success(new JsonObject + { + ["changeSet"] = ToolJson.ChangeSet(set), + ["checks"] = submission.Run is { } run ? ToolJson.CheckRun(run) : null, + ["job"] = submission.Run is null ? ToolJson.Job(submission.Job) : null, + ["next"] = set.State switch + { + ChangeSetState.ChecksFailed => + "Checks failed: each failing result says what to change. Call reopen_change_set, fix the items and submit the set again.", + ChangeSetState.AwaitingReview => "Checks passed, and a person reviews the set next. Follow its state with get_change_set.", + _ => $"Checks run in the background as job {submission.Job.Id}. Follow them with get_change_set_checks.", + }, + }); + } + private static async Task MoveAsync(ToolInvocation call, ChangeSetState target, CancellationToken cancellationToken) { var arguments = new ToolArgumentsReader(call.Arguments); diff --git a/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs b/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs index 787876d20..a7aacfe11 100644 --- a/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/CheckTools.cs @@ -44,6 +44,15 @@ public sealed class CheckTools : IToolProvider // Not read-only: it stores nothing, but an LLM-judged check spends the agent's model budget. readOnly: false, CheckDraftAsync), + new AgentTool( + "get_change_set_checks", + "Get a change set's check results", + "Returns the latest check run of a change set (CHK-03): its status, cost and each item's results, with findings that say " + + "what to change and where, plus the background job that runs the checks with its progress. A set's checks run when it " + + "is submitted; if they failed, call reopen_change_set, fix the items and submit the set again.", + ToolSchemas.Object(ToolSchemas.Uuid("id", "The change set's id.")), + readOnly: true, + ChangeSetChecksAsync), ]; private static async Task CheckDraftAsync(ToolInvocation call, CancellationToken cancellationToken) @@ -96,13 +105,13 @@ [new Error(ToolErrorCodes.ArgumentInvalid, $"The entry {entryId} is of the conte var run = await services.GetRequiredService().RunAsync( new CheckSubject(fields.Value, sources, slug, definition, changed), checks == "deterministic" ? CheckSelection.Deterministic : CheckSelection.All, - cancellationToken); + cancellationToken: cancellationToken); // A draft the content type refuses cannot be proposed, whatever the checks say. return ToolResult.Success(new JsonObject { ["status"] = ToolJson.Name(refused.Count > 0 ? CheckStatus.Fail : CheckResult.Overall(run.Results)), - ["results"] = new JsonArray([.. run.Results.Select(Result)]), + ["results"] = new JsonArray([.. run.Results.Select(ToolJson.CheckResult)]), ["skipped"] = new JsonArray([.. run.Skipped.Select(name => JsonValue.Create(name))]), ["fieldErrors"] = new JsonArray([.. refused.Select(error => new JsonObject { ["code"] = error.Code, ["message"] = error.Message })]), }); @@ -119,26 +128,21 @@ private static List Changed(JsonElement draft, JsonElement latest) .ToList(); } - private static JsonObject Result(CheckResult result) + private static async Task ChangeSetChecksAsync(ToolInvocation call, CancellationToken cancellationToken) { - return new JsonObject + var arguments = new ToolArgumentsReader(call.Arguments); + var id = arguments.RequiredGuid("id"); + if (arguments.Errors() is { Count: > 0 } errors) { - ["check"] = result.Check, - ["version"] = result.Version, - ["type"] = result.Type, - ["kind"] = ToolJson.Name(result.Kind), - ["severity"] = ToolJson.Name(result.Severity), - ["description"] = result.Description, - ["status"] = ToolJson.Name(result.Status), - ["findings"] = new JsonArray([.. result.Findings.Select(finding => new JsonObject - { - ["message"] = finding.Message, - ["pointer"] = finding.Pointer, - ["excerpt"] = finding.Excerpt, - })]), - ["model"] = result.Model is { } model - ? new JsonObject { ["callId"] = model.CallId.ToString(), ["provider"] = model.Provider, ["model"] = model.Model, ["cost"] = model.Cost } - : null, - }; + return ToolResult.Failure(errors); + } + + var state = await call.Services.GetRequiredService().GetAsync(id!.Value, cancellationToken); + return ToolResult.From(state, read => new JsonObject + { + ["changeSet"] = ToolJson.ChangeSet(read.ChangeSet), + ["checks"] = read.Run is { } run ? ToolJson.CheckRun(run) : null, + ["job"] = read.Job is { } job ? ToolJson.Job(job) : null, + }); } } diff --git a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs index 795b5e97a..f2f21b614 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -6,6 +6,9 @@ using FluentCMS.Core.Identity; using FluentCMS.Core.Schema; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Jobs; + namespace FluentCMS.Core.Builtin.Tools; // How the built-in tools show the model to an agent. Names of states and kinds are snake_case, as the audit log's operations are. @@ -28,6 +31,64 @@ public static JsonObject Summary(ContentTypeSummary type) }; } + public static JsonObject CheckResult(CheckResult result) + { + return new JsonObject + { + ["check"] = result.Check, + ["version"] = result.Version, + ["type"] = result.Type, + ["kind"] = Name(result.Kind), + ["severity"] = Name(result.Severity), + ["description"] = result.Description, + ["status"] = Name(result.Status), + ["findings"] = new JsonArray([.. result.Findings.Select(finding => new JsonObject + { + ["message"] = finding.Message, + ["pointer"] = finding.Pointer, + ["excerpt"] = finding.Excerpt, + })]), + ["model"] = result.Model is { } model + ? new JsonObject { ["callId"] = model.CallId.ToString(), ["provider"] = model.Provider, ["model"] = model.Model, ["cost"] = model.Cost } + : null, + }; + } + + public static JsonObject CheckRun(ChangeSetCheckRun run) + { + return new JsonObject + { + ["number"] = run.Number, + ["mode"] = Name(run.Mode), + ["status"] = Name(run.Status), + ["cost"] = run.Cost, + ["startedAt"] = run.StartedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["finishedAt"] = run.FinishedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + ["items"] = new JsonArray([.. run.Items.Select(item => new JsonObject + { + ["itemKind"] = Name(item.ItemKind), + ["itemId"] = item.ItemId.ToString(), + ["version"] = item.Version, + ["contentType"] = item.ContentType, + ["results"] = new JsonArray([.. item.Results.Select(CheckResult)]), + })]), + ["problems"] = new JsonArray([.. run.Problems.Select(problem => JsonValue.Create(problem))]), + }; + } + + public static JsonObject Job(Job job) + { + return new JsonObject + { + ["id"] = job.Id.ToString(), + ["state"] = Name(job.State), + ["done"] = job.Progress.Done, + ["total"] = job.Progress.Total, + ["message"] = job.Progress.Message, + ["lastError"] = job.LastError, + }; + } + public static JsonObject ContentType(ContentType type) { var fields = new JsonArray(); diff --git a/src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs b/src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs new file mode 100644 index 000000000..09a2dfb21 --- /dev/null +++ b/src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs @@ -0,0 +1,70 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Checks; + +// Where a check run ran: on the submitting request for a small set whose checks are all deterministic, or as a job (ADR-0010). +public enum CheckRunMode +{ + Inline, + Job, +} + +// The results of one item of a set: the version its operation proposes, the content type it belongs to, and each active check's +// verdict on it. +public sealed record ItemChecks(ItemKind ItemKind, Guid ItemId, int Version, string ContentType, IReadOnlyList Results); + +// One run of the checks on a change set, stored with the set (CHK-03): its results with their evidence, the overall status and what +// its model calls cost. Problems say what kept the engine from checking an item, or the set, and fail the run. A set reopened and +// submitted again runs again, so runs are numbered from 1. +public sealed record ChangeSetCheckRun( + Guid Id, + Guid ChangeSetId, + int Number, + CheckRunMode Mode, + DateTimeOffset StartedAt, + DateTimeOffset FinishedAt, + CheckStatus Status, + decimal Cost, + IReadOnlyList Items, + IReadOnlyList Problems); + +// A submitted set, with its check run when it ran inline and the job that runs or backs it. +public sealed record ChangeSetSubmission(ChangeSet ChangeSet, ChangeSetCheckRun? Run, Job Job); + +// A set's latest check run, null before one finishes, and its latest check job, which shows a run's progress. +public sealed record ChangeSetChecksState(ChangeSet ChangeSet, ChangeSetCheckRun? Run, Job? Job); + +// The check runs of change sets, which Infrastructure stores. +public interface ICheckRuns +{ + // Inside the caller's unit of work, so the run commits with the move it decides; the run comes back with its number. + Task RecordAsync(ChangeSetCheckRun run, CancellationToken cancellationToken = default); + + Task LatestAsync(Guid changeSetId, CancellationToken cancellationToken = default); +} + +// Checks run on every change set before routing (CHK-03). A set is submitted through here, so the checks are sure to run on it. +public interface IChangeSetChecks +{ + // Moves an open set to checks running and enqueues its check run with it. A small set whose active checks are all deterministic + // is then checked at once (PERF-02); its job only waits in case that run did not finish. Anything else runs as the job. + Task> SubmitAsync(Guid changeSetId, CancellationToken cancellationToken = default); + + // Runs the active checks on every entry and entity the set proposes, stores the results and moves the set: to checks failed if + // any check fails, and to awaiting review otherwise. Made as the check engine (ADR-0012) for the submission whose job is named; + // a set no longer in checks running for it is left as it is. Progress gets the items done and the total, and false from it + // stops the run. + Task> RunAsync( + Guid changeSetId, + Guid jobId, + CheckRunMode mode, + Func>? progress = null, + CancellationToken cancellationToken = default); + + // Fails the set with a problem when its checks cannot run at all, so it does not stay in checks running for good. + Task> AbandonAsync(Guid changeSetId, Guid jobId, CheckRunMode mode, CancellationToken cancellationToken = default); + + Task> GetAsync(Guid changeSetId, CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs new file mode 100644 index 000000000..40bc19d24 --- /dev/null +++ b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs @@ -0,0 +1,343 @@ +using System.Text.Json; +using FluentCMS.Core.Audit; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Content; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Schema; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Checks; + +// Checks read and judge outside any unit of work, since a model may take a while to answer; only the results and the move they +// decide are written, together, as the check engine (ADR-0012). Each submission has one job, and a run belongs to it: a run of an +// earlier submission, or one that another run of the same submission beat, changes nothing. +internal sealed class ChangeSetChecks( + IChangeSetDirectory changeSets, + IEntryDirectory entries, + IContentTypeDirectory contentTypes, + ICheckDirectory checks, + CheckTypeRegistry checkTypes, + ICheckRunner runner, + ICheckRuns runs, + IJobQueue jobs, + IUnitOfWork unitOfWork, + IAuditLog auditLog, + IServiceScopeFactory scopes, + TimeProvider clock) : IChangeSetChecks +{ + // The most items a set may have to be checked on the request that submits it, which keeps that request within PERF-02's ten + // seconds; larger sets, and any set an LLM-judged check reads, are checked as a job (ADR-0010). + public const int InlineItems = 50; + + public const string JobType = "checks.run"; + + // Enough to ride out a short outage, after which the last attempt fails the set rather than leave it in checks running. + public const int JobAttempts = 6; + + public const string LeaseLost = "check_run_lease_lost"; + + private const string SubjectType = "change_set"; + + // How long the job behind an inline run waits before it runs the checks itself, should that run not have finished. + private static readonly TimeSpan InlineGrace = TimeSpan.FromMinutes(2); + + private static readonly TimeSpan ProgressInterval = TimeSpan.FromSeconds(1); + + public async Task> SubmitAsync(Guid changeSetId, CancellationToken cancellationToken = default) + { + var read = await changeSets.GetAsync(changeSetId, cancellationToken); + if (!read.IsSuccess) + { + return Result.Failure(read.Errors); + } + + var inline = read.Value.Operations.Count <= InlineItems && !await AnyJudgedAsync(cancellationToken); + + Job job; + ChangeSet submitted; + await using (var work = await unitOfWork.BeginAsync(cancellationToken)) + { + var moved = await changeSets.TransitionAsync(changeSetId, ChangeSetState.ChecksRunning, cancellationToken); + if (!moved.IsSuccess) + { + // A refusal keeps its security event (SEC-08); a conflict has already rolled the work back, so it cannot commit. + if (!moved.Errors.Any(error => error.Code == ChangeSetErrorCodes.Conflict)) + { + await work.CommitAsync(cancellationToken); + } + + return Result.Failure(moved.Errors); + } + + // The job commits with the move, so a set in checks running always has a run coming (ADR-0010). + job = await jobs.EnqueueAsync( + new NewJob(JobType, JsonSerializer.SerializeToElement(new { changeSetId })) + { + Subject = new JobSubject(SubjectType, changeSetId), + RunAt = inline ? clock.GetUtcNow() + InlineGrace : null, + MaxAttempts = JobAttempts, + }, + cancellationToken); + await work.CommitAsync(cancellationToken); + submitted = moved.Value; + } + + if (!inline) + { + return Result.Success(new ChangeSetSubmission(submitted, null, job)); + } + + // The set is submitted now whatever becomes of the request, so the run is not cancelled with it; if it does not finish, the + // job checks the set instead. + await using var engine = scopes.CreateScopeActingAs(SystemInitiator.Checks); + var ran = await engine.ServiceProvider.GetRequiredService() + .RunAsync(changeSetId, job.Id, CheckRunMode.Inline, null, CancellationToken.None); + + var after = await changeSets.GetAsync(changeSetId, CancellationToken.None); + return Result.Success(new ChangeSetSubmission( + after.IsSuccess ? after.Value : submitted, + ran.IsSuccess ? ran.Value : null, + job)); + } + + public async Task> RunAsync( + Guid changeSetId, + Guid jobId, + CheckRunMode mode, + Func>? progress = null, + CancellationToken cancellationToken = default) + { + var current = await CurrentAsync(changeSetId, jobId, cancellationToken); + if (!current.IsSuccess || current.Value is not { } set) + { + return current.IsSuccess ? Result.Success(await runs.LatestAsync(changeSetId, cancellationToken)) : Result.Failure(current.Errors); + } + + // Read once, so every item is judged by the same checks even if one is activated during the run. + var started = clock.GetUtcNow(); + var active = await checks.ListActiveAsync(cancellationToken); + var (subjects, problems) = await SubjectsAsync(set, cancellationToken); + var chargedTo = (set.Initiator as AgentInitiator)?.CredentialId; + var items = new List(); + var done = 0; + var reported = started; + + foreach (var (operation, contentType, subject) in subjects) + { + // An item no check applies to is left out, as it is when the run is read back. + var itemRun = await runner.RunAsync(subject, active, CheckSelection.All, chargedTo, cancellationToken); + if (itemRun.Results.Count > 0) + { + items.Add(new ItemChecks(operation.ItemKind, operation.ItemId, operation.ProposedVersion!.Value, contentType, itemRun.Results)); + } + + // Progress is written at most once a second, and once at the end, so a large set is not slowed by its own reports. + done++; + if (progress is null || (done < subjects.Count && clock.GetUtcNow() - reported < ProgressInterval)) + { + continue; + } + + reported = clock.GetUtcNow(); + if (!await progress(done, subjects.Count)) + { + return Result.Failure(LeaseLost, $"The check run of change set {changeSetId} stopped: another worker has its job."); + } + } + + var results = items.SelectMany(item => item.Results).ToList(); + var status = problems.Count > 0 ? CheckStatus.Fail : CheckResult.Overall(results); + var run = new ChangeSetCheckRun( + Guid.CreateVersion7(), + changeSetId, + 0, + mode, + started, + clock.GetUtcNow(), + status, + results.Sum(result => result.Model?.Cost ?? 0), + items, + problems); + + return await FinishAsync(run, jobId, cancellationToken); + } + + public async Task> AbandonAsync(Guid changeSetId, Guid jobId, CheckRunMode mode, CancellationToken cancellationToken = default) + { + var current = await CurrentAsync(changeSetId, jobId, cancellationToken); + if (!current.IsSuccess || current.Value is null) + { + return current.IsSuccess ? Result.Success(await runs.LatestAsync(changeSetId, cancellationToken)) : Result.Failure(current.Errors); + } + + var now = clock.GetUtcNow(); + return await FinishAsync( + new ChangeSetCheckRun( + Guid.CreateVersion7(), + changeSetId, + 0, + mode, + now, + now, + CheckStatus.Fail, + 0, + [], + [$"The checks could not run on this change set. Reopen it and submit it again; if they still cannot run, ask a person to look at job {jobId}."]), + jobId, + cancellationToken); + } + + public async Task> GetAsync(Guid changeSetId, CancellationToken cancellationToken = default) + { + var read = await changeSets.GetAsync(changeSetId, cancellationToken); + if (!read.IsSuccess) + { + return Result.Failure(read.Errors); + } + + var run = await runs.LatestAsync(changeSetId, cancellationToken); + return Result.Success(new ChangeSetChecksState(read.Value, run, await LatestJobAsync(changeSetId, cancellationToken))); + } + + // The set when this job's submission is the one in checks running, and null when the set has moved on or been submitted again. + private async Task> CurrentAsync(Guid changeSetId, Guid jobId, CancellationToken cancellationToken) + { + var read = await changeSets.GetAsync(changeSetId, cancellationToken); + if (!read.IsSuccess) + { + return Result.Failure(read.Errors); + } + + var current = read.Value.State == ChangeSetState.ChecksRunning && (await LatestJobAsync(changeSetId, cancellationToken))?.Id == jobId; + return Result.Success(current ? read.Value : null); + } + + // Records the run and makes the move it decides in one unit of work, which commits only if the run's submission is still the + // set's: the job is checked after the move, whose row lock makes a submission that commits first visible to it. + private async Task> FinishAsync(ChangeSetCheckRun run, Guid jobId, CancellationToken cancellationToken) + { + var changeSetId = run.ChangeSetId; + Result moved; + var committed = false; + await using (var work = await unitOfWork.BeginAsync(cancellationToken)) + { + await runs.RecordAsync(run, cancellationToken); + await auditLog.AppendAsync(new NewAuditEvent("change_set.checked") { SubjectType = SubjectType, SubjectId = changeSetId, ChangeSetId = changeSetId }, cancellationToken); + + // Each judgement names its model and prompt, so the prompt can be read back from the audit log (CHK-05). + foreach (var result in run.Items.SelectMany(item => item.Results).Where(result => result.Model is not null)) + { + await auditLog.AppendAsync( + new NewAuditEvent("check.judged") + { + SubjectType = "check", + SubjectId = result.CheckId, + ChangeSetId = changeSetId, + Model = result.Model!.Model, + PromptHash = result.Model.PromptHash, + }, + cancellationToken); + } + + // Routing decides what happens to a set that passes (F-RTE); until it does, every set that passes waits for review. + var target = run.Status == CheckStatus.Fail ? ChangeSetState.ChecksFailed : ChangeSetState.AwaitingReview; + moved = await changeSets.TransitionAsync(changeSetId, target, cancellationToken); + if (moved.IsSuccess && (await LatestJobAsync(changeSetId, cancellationToken))?.Id == jobId) + { + await work.CommitAsync(cancellationToken); + committed = true; + } + } + + // Read back as stored, so the run looks the same here as wherever it is read later. + if (committed) + { + return Result.Success(await runs.LatestAsync(changeSetId, cancellationToken)); + } + + // Rolled back. A set still in checks running for this submission met a write that another attempt can get past. + var current = await CurrentAsync(changeSetId, jobId, cancellationToken); + if (current.IsSuccess && current.Value is not null) + { + return Result.Failure(moved.IsSuccess ? [new Error(ChangeSetErrorCodes.Conflict, $"Change set {changeSetId} changed during its check run.")] : moved.Errors); + } + + return Result.Success(await runs.LatestAsync(changeSetId, cancellationToken)); + } + + private async Task LatestJobAsync(Guid changeSetId, CancellationToken cancellationToken) + { + return (await jobs.ListForSubjectAsync(new JobSubject(SubjectType, changeSetId), cancellationToken)).FirstOrDefault(job => job.Type == JobType); + } + + private async Task AnyJudgedAsync(CancellationToken cancellationToken) + { + return (await checks.ListActiveAsync(cancellationToken)).Any(check => checkTypes.Find(check.Definition.Type)?.Kind == CheckKind.LlmJudged); + } + + // Every entry and entity the set creates or updates, at the version it proposes, with the fields an update changes. Content + // types are checked by the schema itself, and a delete leaves nothing to check. An item that cannot be read, such as an entry + // deleted since it was proposed, is a problem that fails the run rather than one that stops it. + private async Task<(List<(ChangeOperation Operation, string ContentType, CheckSubject Subject)> Subjects, List Problems)> SubjectsAsync( + ChangeSet set, + CancellationToken cancellationToken) + { + var subjects = new List<(ChangeOperation, string, CheckSubject)>(); + var problems = new List(); + var types = new Dictionary<(string Slug, int Version), ContentType>(); + foreach (var operation in set.Operations.Where(operation => + operation.ItemKind is ItemKind.Entry or ItemKind.Entity && operation.ProposedVersion is not null)) + { + var entry = await entries.GetAsync(operation.ItemId, cancellationToken); + var proposed = entry.IsSuccess ? await entries.GetVersionAsync(operation.ItemId, operation.ProposedVersion!.Value, cancellationToken) : null; + var before = proposed is { IsSuccess: true } && operation.Kind == OperationKind.Update && operation.BaseVersion is { } baseVersion + ? await entries.GetVersionAsync(operation.ItemId, baseVersion, cancellationToken) + : null; + if (Problem(operation, entry.Errors, proposed?.Errors, before?.Errors) is { } unreadable) + { + problems.Add(unreadable); + continue; + } + + var key = (entry.Value.ContentType, proposed!.Value.ContentTypeVersion); + if (!types.TryGetValue(key, out var type)) + { + var read = await contentTypes.GetVersionAsync(key.ContentType, key.ContentTypeVersion, cancellationToken); + if (Problem(operation, read.Errors) is { } missing) + { + problems.Add(missing); + continue; + } + + type = types[key] = read.Value; + } + + var changed = before is null ? null : Changed(proposed.Value.Fields, before.Value.Fields); + subjects.Add((operation, entry.Value.ContentType, new CheckSubject(proposed.Value.Fields, set.Sources, entry.Value.ContentType, type.Definition, changed))); + } + + return (subjects, problems); + } + + private static string? Problem(ChangeOperation operation, params IReadOnlyList?[] errors) + { + var error = errors.FirstOrDefault(list => list is { Count: > 0 })?[0]; + return error is null + ? null + : $"The {operation.ItemKind.ToString().ToLowerInvariant()} {operation.ItemId} could not be checked: {error.Message} " + + "Restore it, or reopen the change set and remove its operation."; + } + + private static List Changed(JsonElement proposed, JsonElement before) + { + return proposed.EnumerateObject() + .Where(field => before.ValueKind != JsonValueKind.Object + || !before.TryGetProperty(field.Name, out var value) + || !JsonElement.DeepEquals(value, field.Value)) + .Select(field => field.Name) + .ToList(); + } +} diff --git a/src/FluentCMS.Core/Checks/CheckInput.cs b/src/FluentCMS.Core/Checks/CheckInput.cs index e7694c90e..3c34555cf 100644 --- a/src/FluentCMS.Core/Checks/CheckInput.cs +++ b/src/FluentCMS.Core/Checks/CheckInput.cs @@ -4,7 +4,8 @@ namespace FluentCMS.Core.Checks; // The services are the caller's own scope, so a type that needs more than the content, such as a model, reads it as the caller. -public sealed record CheckInput(JsonElement Settings, CheckSubject Subject, IServiceProvider Services); +// ChargedTo names the agent a model call counts against when the caller is an engine judging that agent's change set (SEC-04). +public sealed record CheckInput(JsonElement Settings, CheckSubject Subject, IServiceProvider Services, Guid? ChargedTo = null); // One piece of content as a check sees it: its fields as an entry stores them, the sources its change cites, and its content type // when it has one. The fields hold only the ones the check's scope names. ChangedFields names the fields the change touches, and is diff --git a/src/FluentCMS.Core/Checks/CheckResult.cs b/src/FluentCMS.Core/Checks/CheckResult.cs index f7409f1c7..2438acd65 100644 --- a/src/FluentCMS.Core/Checks/CheckResult.cs +++ b/src/FluentCMS.Core/Checks/CheckResult.cs @@ -61,8 +61,18 @@ public sealed record CheckRun(IReadOnlyList Results, IReadOnlyList< // neither is a run that includes LLM-judged checks: the caller runs it before any unit of work begins. public interface ICheckRunner { + // ChargedTo names the agent whose spend cap LLM-judged checks count against, when the caller is not that agent (SEC-04). Task RunAsync( CheckSubject subject, CheckSelection selection = CheckSelection.All, + Guid? chargedTo = null, + CancellationToken cancellationToken = default); + + // Runs the given active checks, read once by a caller that checks many subjects, so each is judged by the same checks. + Task RunAsync( + CheckSubject subject, + IReadOnlyList active, + CheckSelection selection = CheckSelection.All, + Guid? chargedTo = null, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Core/Checks/CheckRunner.cs b/src/FluentCMS.Core/Checks/CheckRunner.cs index a7f96c836..29aac6782 100644 --- a/src/FluentCMS.Core/Checks/CheckRunner.cs +++ b/src/FluentCMS.Core/Checks/CheckRunner.cs @@ -7,13 +7,25 @@ internal sealed class CheckRunner(ICheckDirectory checks, CheckTypeRegistry chec public async Task RunAsync( CheckSubject subject, CheckSelection selection = CheckSelection.All, + Guid? chargedTo = null, + CancellationToken cancellationToken = default) + { + return await RunAsync(subject, await checks.ListActiveAsync(cancellationToken), selection, chargedTo, cancellationToken); + } + + public async Task RunAsync( + CheckSubject subject, + IReadOnlyList active, + CheckSelection selection = CheckSelection.All, + Guid? chargedTo = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(subject); + ArgumentNullException.ThrowIfNull(active); var results = new List(); var skipped = new List(); - foreach (var check in await checks.ListActiveAsync(cancellationToken)) + foreach (var check in active) { var definition = check.Definition; var kind = checkTypes.Find(definition.Type)?.Kind ?? CheckKind.Deterministic; @@ -30,7 +42,7 @@ public async Task RunAsync( var outcome = await checkTypes.EvaluateAsync( definition.Type, - new CheckInput(definition.Settings, definition.Scope.Narrow(subject), services), + new CheckInput(definition.Settings, definition.Scope.Narrow(subject), services, chargedTo), cancellationToken); results.Add(new CheckResult( diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs index 3d81778ba..3c284d584 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -37,6 +37,9 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic // Reads the active checks through ICheckDirectory, which Infrastructure provides. services.AddScoped(); + // Reads and writes through IChangeSetDirectory, IEntryDirectory, ICheckRuns and IJobQueue, which Infrastructure provides. + services.AddScoped(); + return services; } } diff --git a/src/FluentCMS.Core/Identity/ActingInitiator.cs b/src/FluentCMS.Core/Identity/ActingInitiator.cs new file mode 100644 index 000000000..089d2ef3f --- /dev/null +++ b/src/FluentCMS.Core/Identity/ActingInitiator.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Identity; + +// The initiator a scope acts as instead of the host's caller: a job's, or an engine's when the product does work on its own inside +// a request, such as the check run a submission starts (ADR-0012). Null leaves the host's caller. +public sealed class ActingInitiator +{ + public Initiator? Initiator { get; set; } +} + +public static class ActingInitiatorExtensions +{ + // Wraps the ICurrentInitiator a host registered last, so a scope can act as another initiator. A host calls it after + // registering its own. + public static IServiceCollection AddActingInitiator(this IServiceCollection services) + { + var registered = services.Last(descriptor => descriptor.ServiceType == typeof(ICurrentInitiator)); + services.Remove(registered); + services.AddScoped(); + services.AddScoped(provider => new ActingCurrentInitiator( + provider.GetRequiredService(), + registered.ImplementationInstance as ICurrentInitiator + ?? registered.ImplementationFactory?.Invoke(provider) as ICurrentInitiator + ?? (ICurrentInitiator)ActivatorUtilities.CreateInstance(provider, registered.ImplementationType!))); + + return services; + } + + // A scope of its own that acts as the initiator given, whoever the host's caller is. + public static AsyncServiceScope CreateScopeActingAs(this IServiceScopeFactory scopes, Initiator? initiator) + { + ArgumentNullException.ThrowIfNull(scopes); + + var scope = scopes.CreateAsyncScope(); + var acting = scope.ServiceProvider.GetService() + ?? throw new InvalidOperationException("The host does not let a scope act as another initiator; it calls AddActingInitiator."); + acting.Initiator = initiator; + return scope; + } + + private sealed class ActingCurrentInitiator(ActingInitiator acting, ICurrentInitiator host) : ICurrentInitiator + { + public Initiator? Current => acting.Initiator ?? host.Current; + } +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index fb11e874a..4f28ba9a6 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -172,17 +172,11 @@ public static IHost BuildMcpHost( return builder.Build(); } - // A job runs as whoever enqueued it, whatever the host's own caller is, so the initiator each host registered is wrapped. + // A job runs as whoever enqueued it, or as the engine its type names, whatever the host's own caller is, so the initiator each + // host registered is wrapped. private static void RunJobsAsTheirInitiator(IServiceCollection services) { - var registered = services.Last(descriptor => descriptor.ServiceType == typeof(ICurrentInitiator)); - services.Remove(registered); - services.AddScoped(); - services.AddScoped(provider => new JobAwareInitiator( - provider.GetRequiredService(), - registered.ImplementationInstance as ICurrentInitiator - ?? registered.ImplementationFactory?.Invoke(provider) as ICurrentInitiator - ?? (ICurrentInitiator)ActivatorUtilities.CreateInstance(provider, registered.ImplementationType!))); + services.AddActingInitiator(); } // One composition for both entry modes, so the stdio host opens the same database and runs the same services. diff --git a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs index 94f307fcb..a9a376690 100644 --- a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs +++ b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs @@ -1,3 +1,4 @@ +using FluentCMS.Core.Identity; using FluentCMS.Core.Jobs; using Microsoft.Extensions.Options; @@ -71,7 +72,7 @@ private async Task RunNextAsync(string owner, JobQueueOptions settings, Ca return true; } - runScope.ServiceProvider.GetRequiredService().Initiator = handler.RunAs ?? job.Initiator; + runScope.ServiceProvider.GetRequiredService().Initiator = handler.RunAs ?? job.Initiator; var context = new JobContext( job, diff --git a/src/FluentCMS.Host/Jobs/JobRun.cs b/src/FluentCMS.Host/Jobs/JobRun.cs deleted file mode 100644 index 844576bcd..000000000 --- a/src/FluentCMS.Host/Jobs/JobRun.cs +++ /dev/null @@ -1,16 +0,0 @@ -using FluentCMS.Core.Identity; - -namespace FluentCMS.Host.Jobs; - -// The job a scope runs, set by the worker alone. Its initiator is who enqueued the job, so the handler's changes name them. -internal sealed class JobRun -{ - public Initiator? Initiator { get; set; } -} - -// Wraps the host's own ICurrentInitiator, the request's or the stdio message's caller, so that inside a job's scope the job's -// initiator is the caller instead (AUD-01). -internal sealed class JobAwareInitiator(JobRun run, ICurrentInitiator host) : ICurrentInitiator -{ - public Initiator? Current => run.Initiator ?? host.Current; -} diff --git a/src/FluentCMS.Infrastructure/Checks/CheckRunConfiguration.cs b/src/FluentCMS.Infrastructure/Checks/CheckRunConfiguration.cs new file mode 100644 index 000000000..921af91c5 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Checks/CheckRunConfiguration.cs @@ -0,0 +1,53 @@ +using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.ChangeSets; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace FluentCMS.Infrastructure.Checks; + +internal sealed class CheckRunConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("CheckRuns", table => table.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1")); + row.HasKey(run => run.Id); + row.Property(run => run.Id).ValueGeneratedNever(); + row.Property(run => run.Cost).HasPrecision(18, 6); + row.Property(run => run.Problems).IsRequired(); + + // Stored as names, so the table reads without the enums and a reordered enum cannot change stored meaning. + row.Property(run => run.Mode).HasConversion().HasMaxLength(16); + row.Property(run => run.Status).HasConversion().HasMaxLength(16); + + row.HasOne().WithMany().HasForeignKey(run => run.ChangeSetId).OnDelete(DeleteBehavior.Restrict); + + // Also what stops two runs that finish at once from both being a set's run of one number. + row.HasIndex(run => new { run.ChangeSetId, run.Number }).IsUnique(); + } +} + +internal sealed class CheckResultConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("CheckResults"); + row.HasKey(result => result.Id); + row.Property(result => result.Id).ValueGeneratedNever(); + row.Property(result => result.ContentType).HasMaxLength(SchemaNames.MaxLength).IsRequired(); + row.Property(result => result.Check).HasMaxLength(SchemaNames.MaxLength).IsRequired(); + row.Property(result => result.CheckType).HasMaxLength(SchemaNames.MaxLength).IsRequired(); + row.Property(result => result.Description).IsRequired(); + row.Property(result => result.Findings).IsRequired(); + row.Property(result => result.Provider).HasMaxLength(32); + row.Property(result => result.Model).HasMaxLength(128); + row.Property(result => result.PromptHash).HasMaxLength(64); + row.Property(result => result.Cost).HasPrecision(18, 6); + row.Property(result => result.ItemKind).HasConversion().HasMaxLength(16); + row.Property(result => result.Kind).HasConversion().HasMaxLength(16); + row.Property(result => result.Severity).HasConversion().HasMaxLength(16); + row.Property(result => result.Status).HasConversion().HasMaxLength(16); + + row.HasOne().WithMany().HasForeignKey(result => result.RunId).OnDelete(DeleteBehavior.Restrict); + row.HasIndex(result => new { result.RunId, result.Position }).IsUnique(); + } +} diff --git a/src/FluentCMS.Infrastructure/Checks/CheckRunRow.cs b/src/FluentCMS.Infrastructure/Checks/CheckRunRow.cs new file mode 100644 index 000000000..638cd69c4 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Checks/CheckRunRow.cs @@ -0,0 +1,74 @@ +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; + +namespace FluentCMS.Infrastructure.Checks; + +// One check run of a change set (CHK-03); its results are CheckResultRows. Inserted once and never changed. +public sealed class CheckRunRow +{ + public Guid Id { get; set; } + + public Guid ChangeSetId { get; set; } + + public int Number { get; set; } + + public CheckRunMode Mode { get; set; } + + public DateTimeOffset StartedAt { get; set; } + + public DateTimeOffset FinishedAt { get; set; } + + public CheckStatus Status { get; set; } + + public decimal Cost { get; set; } + + // A JSON array of what kept the engine from checking an item, or the set. + public string Problems { get; set; } = "[]"; +} + +// One check's verdict on one item of a run, with its findings as JSON and, for an LLM-judged check, the model call it rests on. +public sealed class CheckResultRow +{ + public Guid Id { get; set; } + + public Guid RunId { get; set; } + + // Items in operation order, and checks by name within an item. + public int Position { get; set; } + + public ItemKind ItemKind { get; set; } + + public Guid ItemId { get; set; } + + public int ItemVersion { get; set; } + + public string ContentType { get; set; } = string.Empty; + + public Guid CheckId { get; set; } + + public string Check { get; set; } = string.Empty; + + public int CheckVersion { get; set; } + + public string CheckType { get; set; } = string.Empty; + + public CheckKind Kind { get; set; } + + public CheckSeverity Severity { get; set; } + + public string Description { get; set; } = string.Empty; + + public CheckStatus Status { get; set; } + + public string Findings { get; set; } = "[]"; + + public Guid? ModelCallId { get; set; } + + public string? Provider { get; set; } + + public string? Model { get; set; } + + public string? PromptHash { get; set; } + + public decimal Cost { get; set; } +} diff --git a/src/FluentCMS.Infrastructure/Checks/CheckRuns.cs b/src/FluentCMS.Infrastructure/Checks/CheckRuns.cs new file mode 100644 index 000000000..c5afea21f --- /dev/null +++ b/src/FluentCMS.Infrastructure/Checks/CheckRuns.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using FluentCMS.Core.Checks; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Infrastructure.Checks; + +// Runs are written with the caller's save inside its unit of work, so a run and the move it decides commit together or not at all. +internal sealed class CheckRuns(FluentCmsDbContext context) : ICheckRuns +{ + public async Task RecordAsync(ChangeSetCheckRun run, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(run); + + if (context.Database.CurrentTransaction is null) + { + throw new InvalidOperationException("A check run is recorded inside the unit of work that moves its change set (CHK-03)."); + } + + var latest = await context.CheckRuns.AsNoTracking() + .Where(row => row.ChangeSetId == run.ChangeSetId) + .MaxAsync(row => (int?)row.Number, cancellationToken); + var numbered = run with { Number = (latest ?? 0) + 1 }; + + context.CheckRuns.Add(new CheckRunRow + { + Id = numbered.Id, + ChangeSetId = numbered.ChangeSetId, + Number = numbered.Number, + Mode = numbered.Mode, + StartedAt = numbered.StartedAt, + FinishedAt = numbered.FinishedAt, + Status = numbered.Status, + Cost = numbered.Cost, + Problems = JsonSerializer.Serialize(numbered.Problems), + }); + + var position = 0; + foreach (var item in numbered.Items) + { + foreach (var result in item.Results) + { + context.CheckResults.Add(new CheckResultRow + { + Id = Guid.CreateVersion7(), + RunId = numbered.Id, + Position = position++, + ItemKind = item.ItemKind, + ItemId = item.ItemId, + ItemVersion = item.Version, + ContentType = item.ContentType, + CheckId = result.CheckId, + Check = result.Check, + CheckVersion = result.Version, + CheckType = result.Type, + Kind = result.Kind, + Severity = result.Severity, + Description = result.Description, + Status = result.Status, + Findings = JsonSerializer.Serialize(result.Findings.Select(finding => new StoredFinding(finding.Message, finding.Pointer, finding.Excerpt))), + ModelCallId = result.Model?.CallId, + Provider = result.Model?.Provider, + Model = result.Model?.Model, + PromptHash = result.Model?.PromptHash, + Cost = result.Model?.Cost ?? 0, + }); + } + } + + return numbered; + } + + public async Task LatestAsync(Guid changeSetId, CancellationToken cancellationToken = default) + { + var run = await context.CheckRuns.AsNoTracking() + .Where(row => row.ChangeSetId == changeSetId) + .OrderByDescending(row => row.Number) + .FirstOrDefaultAsync(cancellationToken); + if (run is null) + { + return null; + } + + var results = await context.CheckResults.AsNoTracking() + .Where(row => row.RunId == run.Id) + .OrderBy(row => row.Position) + .ToListAsync(cancellationToken); + + // An item with no check that applies has no rows, so items are the ones that were judged. + var items = results + .GroupBy(row => (row.ItemKind, row.ItemId, row.ItemVersion, row.ContentType)) + .Select(group => new ItemChecks(group.Key.ItemKind, group.Key.ItemId, group.Key.ItemVersion, group.Key.ContentType, group.Select(ToResult).ToList())) + .ToList(); + + var problems = JsonSerializer.Deserialize>(run.Problems) ?? []; + return new ChangeSetCheckRun(run.Id, run.ChangeSetId, run.Number, run.Mode, run.StartedAt, run.FinishedAt, run.Status, run.Cost, items, problems); + } + + private static CheckResult ToResult(CheckResultRow row) + { + var findings = JsonSerializer.Deserialize>(row.Findings) ?? []; + var model = row.ModelCallId is { } callId ? new CheckModelUse(callId, row.Provider!, row.Model!, row.PromptHash!, row.Cost) : null; + + return new CheckResult( + row.CheckId, + row.Check, + row.CheckVersion, + row.CheckType, + row.Kind, + row.Severity, + row.Description, + row.Status, + findings.Select(finding => new CheckFinding(finding.Message, finding.Pointer, finding.Excerpt)).ToList(), + model); + } + + private sealed record StoredFinding(string Message, string Pointer, string? Excerpt); +} diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index d9dd9264e..9a4b7f498 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -124,6 +124,7 @@ private static void AddContent(IServiceCollection services, IConfiguration confi private static void AddChecks(IServiceCollection services) { services.AddScoped(); + services.AddScoped(); } // The worker in Host claims and finishes jobs through IJobLeases; everything else enqueues and reads through IJobQueue. diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs index d5c436710..2c84ae169 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -49,6 +49,10 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC public DbSet ModelCalls => Set(); + public DbSet CheckRuns => Set(); + + public DbSet CheckResults => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -69,6 +73,8 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyConfiguration(new CheckVersionConfiguration()); builder.ApplyConfiguration(new JobConfiguration()); builder.ApplyConfiguration(new ModelCallConfiguration()); + builder.ApplyConfiguration(new CheckRunConfiguration()); + builder.ApplyConfiguration(new CheckResultConfiguration()); builder.Entity(user => { diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.Designer.cs new file mode 100644 index 000000000..e2609ba04 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.Designer.cs @@ -0,0 +1,1346 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261019035255_CheckRuns")] + partial class CheckRuns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckVersion") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("text"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ItemVersion") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelCallId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.cs new file mode 100644 index 000000000..f38b8c23b --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class CheckRuns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CheckRuns", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChangeSetId = table.Column(type: "uuid", nullable: false), + Number = table.Column(type: "integer", nullable: false), + Mode = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: false), + FinishedAt = table.Column(type: "timestamp with time zone", nullable: false), + Status = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Cost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false), + Problems = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CheckRuns", x => x.Id); + table.CheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + table.ForeignKey( + name: "FK_CheckRuns_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "CheckResults", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RunId = table.Column(type: "uuid", nullable: false), + Position = table.Column(type: "integer", nullable: false), + ItemKind = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + ItemId = table.Column(type: "uuid", nullable: false), + ItemVersion = table.Column(type: "integer", nullable: false), + ContentType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CheckId = table.Column(type: "uuid", nullable: false), + Check = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + CheckVersion = table.Column(type: "integer", nullable: false), + CheckType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Kind = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Severity = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Description = table.Column(type: "text", nullable: false), + Status = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Findings = table.Column(type: "text", nullable: false), + ModelCallId = table.Column(type: "uuid", nullable: true), + Provider = table.Column(type: "character varying(32)", maxLength: 32, nullable: true), + Model = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + PromptHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + Cost = table.Column(type: "numeric(18,6)", precision: 18, scale: 6, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CheckResults", x => x.Id); + table.ForeignKey( + name: "FK_CheckResults_CheckRuns_RunId", + column: x => x.RunId, + principalTable: "CheckRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_CheckResults_RunId_Position", + table: "CheckResults", + columns: new[] { "RunId", "Position" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CheckRuns_ChangeSetId_Number", + table: "CheckRuns", + columns: new[] { "ChangeSetId", "Number" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CheckResults"); + + migrationBuilder.DropTable( + name: "CheckRuns"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index 550af8e75..6bc352bda 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -311,6 +311,99 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckVersion") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("text"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ItemVersion") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelCallId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => { b.Property("Id") @@ -343,6 +436,52 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => { b.Property("Id") @@ -1044,6 +1183,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => { b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) @@ -1053,6 +1201,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => { b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.Designer.cs new file mode 100644 index 000000000..381a5aedd --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.Designer.cs @@ -0,0 +1,1336 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261019035242_CheckRuns")] + partial class CheckRuns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckVersion") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ItemVersion") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelCallId") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.cs new file mode 100644 index 000000000..381f59430 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.cs @@ -0,0 +1,100 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class CheckRuns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CheckRuns", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ChangeSetId = table.Column(type: "TEXT", nullable: false), + Number = table.Column(type: "INTEGER", nullable: false), + Mode = table.Column(type: "TEXT", maxLength: 16, nullable: false), + StartedAt = table.Column(type: "TEXT", nullable: false), + FinishedAt = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Cost = table.Column(type: "TEXT", precision: 18, scale: 6, nullable: false), + Problems = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CheckRuns", x => x.Id); + table.CheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + table.ForeignKey( + name: "FK_CheckRuns_ChangeSets_ChangeSetId", + column: x => x.ChangeSetId, + principalTable: "ChangeSets", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "CheckResults", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RunId = table.Column(type: "TEXT", nullable: false), + Position = table.Column(type: "INTEGER", nullable: false), + ItemKind = table.Column(type: "TEXT", maxLength: 16, nullable: false), + ItemId = table.Column(type: "TEXT", nullable: false), + ItemVersion = table.Column(type: "INTEGER", nullable: false), + ContentType = table.Column(type: "TEXT", maxLength: 64, nullable: false), + CheckId = table.Column(type: "TEXT", nullable: false), + Check = table.Column(type: "TEXT", maxLength: 64, nullable: false), + CheckVersion = table.Column(type: "INTEGER", nullable: false), + CheckType = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Kind = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Severity = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Description = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Findings = table.Column(type: "TEXT", nullable: false), + ModelCallId = table.Column(type: "TEXT", nullable: true), + Provider = table.Column(type: "TEXT", maxLength: 32, nullable: true), + Model = table.Column(type: "TEXT", maxLength: 128, nullable: true), + PromptHash = table.Column(type: "TEXT", maxLength: 64, nullable: true), + Cost = table.Column(type: "TEXT", precision: 18, scale: 6, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CheckResults", x => x.Id); + table.ForeignKey( + name: "FK_CheckResults_CheckRuns_RunId", + column: x => x.RunId, + principalTable: "CheckRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_CheckResults_RunId_Position", + table: "CheckResults", + columns: new[] { "RunId", "Position" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CheckRuns_ChangeSetId_Number", + table: "CheckRuns", + columns: new[] { "ChangeSetId", "Number" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CheckResults"); + + migrationBuilder.DropTable( + name: "CheckRuns"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index 02a71e309..cadbe483c 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -305,6 +305,99 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckVersion") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ItemVersion") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelCallId") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => { b.Property("Id") @@ -337,6 +430,52 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => { b.Property("Id") @@ -1034,6 +1173,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => { b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) @@ -1043,6 +1191,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => { b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) diff --git a/tests/FluentCMS.IntegrationTests/Checks/CheckDirectoryTests.cs b/tests/FluentCMS.IntegrationTests/Checks/CheckDirectoryTests.cs index 3f6e7ecbc..35beb1edc 100644 --- a/tests/FluentCMS.IntegrationTests/Checks/CheckDirectoryTests.cs +++ b/tests/FluentCMS.IntegrationTests/Checks/CheckDirectoryTests.cs @@ -248,7 +248,8 @@ public async Task Of_two_activations_at_the_same_time_the_second_changes_nothing directory => directory.ActivateAsync("no-superlatives", 1), directory => directory.ActivateAsync("no-superlatives", 1)); - Assert.Equal(CheckErrorCodes.VersionConflict, Assert.Single(lost.Errors).Code); + // Under load the second may read only after the first commits, and then finds the version already active. + Assert.Contains(Assert.Single(lost.Errors).Code, new[] { CheckErrorCodes.VersionConflict, CheckErrorCodes.AlreadyActive }); Assert.Single(await scenario.AuditRowsAsync(), row => row.Operation == "check.activated"); Assert.Equal(1, await scenario.QueryAsync(context => context.Checks.Select(row => row.ActiveVersion).SingleAsync())); } diff --git a/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs new file mode 100644 index 000000000..ff26ef820 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs @@ -0,0 +1,289 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Content; +using FluentCMS.Core.Extensions; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Results; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.IntegrationTests.Mcp; + +// CHK-03: checks run on every change set before routing, and their results, evidence and cost are stored with the set. +public sealed class ChangeSetChecksTests +{ + private static readonly Dictionary FastQueue = new() { ["JobQueue:PollingInterval"] = "00:00:00.050" }; + + // S2: a set with "best in class", an image without alt text and copy that cites no source fails three checks that say what to change; + // the fixed content passes. The checks, not the agent, move the set. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_small_set_is_checked_when_it_is_submitted_and_the_check_engine_moves_it(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await ProductChecksAsync(agent); + + var failing = await SubmitAsync(agent, "s2", "The best in class kettle ![](/media/kettle.jpg)"); + var passing = await SubmitAsync(agent, "s2-fixed", "A kettle that boils in two minutes ![A steel kettle](/media/kettle.jpg)", "exports/catalog.csv"); + + Assert.Equal("checks_failed", (string?)failing["changeSet"]!["state"]); + var checks = failing["checks"]!; + Assert.Equal(("inline", "fail", 1), ((string?)checks["mode"], (string?)checks["status"], (int)checks["number"]!)); + var results = checks["items"]!.AsArray().Single()!["results"]!.AsArray(); + Assert.Equal(["alt-text", "no-superlatives", "sourced-copy"], results.Select(result => (string?)result!["check"])); + Assert.All(results, result => Assert.Equal("fail", (string?)result!["status"])); + Assert.Equal("/title", (string?)results[1]!["findings"]![0]!["pointer"]); + Assert.Null(failing["job"]); + + Assert.Equal("awaiting_review", (string?)passing["changeSet"]!["state"]); + Assert.Equal("pass", (string?)passing["checks"]!["status"]); + + // The run is stored with the set, and the backup job only waits in case it had not finished. + var setId = (string)failing["changeSet"]!["id"]!; + var read = await agent.CallAsync("get_change_set_checks", new() { ["id"] = setId }); + Assert.Equal(1, (int)read["checks"]!["number"]!); + Assert.Equal("queued", (string?)read["job"]!["state"]); + + var events = await agent.QueryAsync(context => context.AuditEvents.AsNoTracking() + .Where(row => row.ChangeSetId == Guid.Parse(setId) || row.SubjectId == Guid.Parse(setId)) + .ToListAsync()); + Assert.Equal("agent", events.Single(row => row.Operation == "change_set.submitted").InitiatorKind); + var byEngine = events.Where(row => row.Operation is "change_set.checked" or "change_set.checks_failed").ToList(); + Assert.Equal(2, byEngine.Count); + Assert.All(byEngine, row => Assert.Equal(("system", "checks", (Guid?)null), (row.InitiatorKind, row.InitiatorName, row.AgentCredentialId))); + } + + // A run belongs to its submission: the job of an earlier one, which may still fire, leaves a set reopened or submitted again + // as it is, and adds no run. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_job_of_an_earlier_submission_changes_nothing(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await ProductChecksAsync(agent); + var first = await SubmitAsync(agent, "s2", "The best in class kettle"); + var setId = Guid.Parse((string)first["changeSet"]!["id"]!); + var earlier = (await agent.CallAsync("get_change_set_checks", new() { ["id"] = setId.ToString() }))["job"]!; + var earlierJob = Guid.Parse((string)earlier["id"]!); + + await agent.CallAsync("reopen_change_set", new() { ["id"] = setId.ToString() }); + var whileOpen = await agent.AsEngineAsync>(checks => checks.RunAsync(setId, earlierJob, CheckRunMode.Job)); + var again = await agent.CallAsync("submit_change_set", new() { ["id"] = setId.ToString() }); + var afterwards = await agent.AsEngineAsync>(checks => checks.RunAsync(setId, earlierJob, CheckRunMode.Job)); + var abandoned = await agent.AsEngineAsync>(checks => checks.AbandonAsync(setId, earlierJob, CheckRunMode.Job)); + + Assert.Equal(1, whileOpen.Value!.Number); + Assert.Equal(2, (int)again["checks"]!["number"]!); + Assert.Equal((2, 2), (afterwards.Value!.Number, abandoned.Value!.Number)); + var read = await agent.CallAsync("get_change_set_checks", new() { ["id"] = setId.ToString() }); + Assert.Equal(("checks_failed", 2), ((string?)read["changeSet"]!["state"], (int)read["checks"]!["number"]!)); + Assert.NotEqual(earlierJob, Guid.Parse((string)read["job"]!["id"]!)); + Assert.Equal(2, await agent.QueryAsync(context => context.CheckRuns.CountAsync(run => run.ChangeSetId == setId))); + } + + // An item the engine cannot read, such as an entry deleted after it was proposed, fails the set with what to do about it, and + // the other items are still checked. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_item_that_cannot_be_read_fails_the_set_with_a_problem(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await ProductChecksAsync(agent); + var kept = await CreateAsync(agent, "both", "The best in class kettle"); + var deleted = await CreateAsync(agent, "both", "A kettle"); + var deletedId = Guid.Parse((string)deleted["entry"]!["id"]!); + Assert.True((await agent.AsPersonAsync(directory => directory.DeleteAsync(deletedId, 1))).IsSuccess); + + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = (string?)kept["changeSet"]!["id"] }); + + Assert.Equal("checks_failed", (string?)submitted["changeSet"]!["state"]); + var checks = submitted["checks"]!; + Assert.Equal("fail", (string?)checks["status"]); + Assert.Contains(deletedId.ToString(), (string?)Assert.Single(checks["problems"]!.AsArray())); + Assert.Equal((string?)kept["entry"]!["id"], (string?)Assert.Single(checks["items"]!.AsArray())!["itemId"]); + } + + // A set with nothing to check passes and waits for review; routing decides more once it lands (F-RTE). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_no_check_applies_to_waits_for_review(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await ProductChecksAsync(agent); + await agent.CreateAsPersonAsync("note"); + + var created = await agent.CallAsync("create_entry", new() + { + ["content_type"] = "note", + ["fields"] = new JsonObject { ["title"] = "The best in class note" }, + ["session_id"] = "notes", + ["rationale"] = "Add a note.", + }); + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = (string?)created["changeSet"]!["id"] }); + + Assert.Equal("awaiting_review", (string?)submitted["changeSet"]!["state"]); + Assert.Equal("pass", (string?)submitted["checks"]!["status"]); + Assert.Empty(submitted["checks"]!["items"]!.AsArray()); + } + + // LLM-judged checks run as a job with progress (PERF-02), as the check engine, and their model calls count against the agent + // whose set they judge (SEC-04). + [Fact] + public async Task A_set_an_LLM_judged_check_reads_is_checked_as_a_job_charged_to_the_agent() + { + await using var agent = await ConnectedAgent.StartAsync( + "sqlite", + settings: FastQueue, + configure: builder => builder.Services.AddFluentCmsExtension()); + await ProductChecksAsync(agent); + await ActivateAsync(agent, "calm", Definition(RecordingJudge.Name, "{}", "A kettle", "BUY!!!")); + + var submitted = await SubmitAsync(agent, "judged", "BUY THIS KETTLE!!!", "exports/catalog.csv"); + var setId = (string)submitted["changeSet"]!["id"]!; + var finished = await FinishedAsync(agent, setId); + + Assert.Equal("checks_running", (string?)submitted["changeSet"]!["state"]); + Assert.Null(submitted["checks"]); + Assert.Equal("checks_failed", (string?)finished["changeSet"]!["state"]); + Assert.Equal(("job", "fail"), ((string?)finished["checks"]!["mode"], (string?)finished["checks"]!["status"])); + Assert.Equal(("succeeded", 1, 1), ((string?)finished["job"]!["state"], (int)finished["job"]!["done"]!, (int)finished["job"]!["total"]!)); + + var (chargedTo, ranAs) = RecordingJudge.Calls.Last(); + Assert.Equal(agent.Credential.Id, chargedTo); + Assert.Equal(SystemInitiator.Checks, ranAs); + } + + // PERF-02: deterministic checks over a 200-item set finish in under ten seconds; a set that large runs as a job. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Deterministic_checks_on_a_200_item_set_finish_in_under_ten_seconds(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider, settings: FastQueue); + await ProductChecksAsync(agent); + + string? setId = null; + for (var item = 1; item <= 200; item++) + { + var created = await agent.CallAsync("create_entry", new() + { + ["content_type"] = "product-page", + ["fields"] = new JsonObject { ["title"] = $"Kettle {item}" }, + ["session_id"] = "catalog", + ["rationale"] = "Import the catalog.", + ["sources"] = new JsonArray("exports/catalog-2026-10-18.csv"), + }); + setId = (string?)created["changeSet"]!["id"]; + } + + var submitted = await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + var finished = await FinishedAsync(agent, setId!); + + Assert.Null(submitted["checks"]); + var run = finished["checks"]!; + Assert.Equal(("job", "pass", 200), ((string?)run["mode"], (string?)run["status"], run["items"]!.AsArray().Count)); + var took = DateTimeOffset.Parse((string)run["finishedAt"]!) - DateTimeOffset.Parse((string)run["startedAt"]!); + Assert.True(took < TimeSpan.FromSeconds(10), $"The run took {took}."); + Assert.Equal("awaiting_review", (string?)finished["changeSet"]!["state"]); + } + + // The checks of the reference scenario (S2), activated by a person, on a product page's title: no "best in class", alt text on + // every image, and a source for the copy. + private static async Task ProductChecksAsync(ConnectedAgent agent) + { + await agent.CreateAsPersonAsync("product-page"); + await ActivateAsync(agent, "no-superlatives", Definition("terms", """{"terms": ["best in class"]}""", "A kettle", "Best in class")); + await ActivateAsync(agent, "alt-text", Definition("altText", "{}", "![A kettle](/k.jpg)", "![](/k.jpg)")); + await ActivateAsync(agent, "sourced-copy", Definition("sources", "{}", "A kettle", "A kettle", ["exports/catalog.csv"])); + } + + private static async Task ActivateAsync(ConnectedAgent agent, string name, CheckDefinition definition) + { + await agent.AsPersonAsync(directory => directory.CreateAsync(new NewCheck(name, definition))); + await agent.AsPersonAsync(directory => directory.ActivateAsync(name, 1)); + } + + private static CheckDefinition Definition(string type, string settings, string passing, string failing, IReadOnlyList? passingSources = null) + { + return new CheckDefinition( + type, + $"The {type} check of the reference scenario.", + JsonSerializer.Deserialize(settings), + [ + new CheckExample("passes", CheckVerdict.Pass, Title(passing), passingSources, "product-page"), + new CheckExample("fails", CheckVerdict.Fail, Title(failing), contentType: "product-page"), + ], + scope: new CheckScope(["product-page"])); + } + + private static JsonElement Title(string title) + { + return JsonSerializer.SerializeToElement(new { title }); + } + + private static async Task SubmitAsync(ConnectedAgent agent, string session, string title, params string[] sources) + { + var created = await CreateAsync(agent, session, title, sources); + return await agent.CallAsync("submit_change_set", new() { ["id"] = (string?)created["changeSet"]!["id"] }); + } + + private static Task CreateAsync(ConnectedAgent agent, string session, string title, params string[] sources) + { + return agent.CallAsync("create_entry", new() + { + ["content_type"] = "product-page", + ["fields"] = new JsonObject { ["title"] = title }, + ["session_id"] = session, + ["rationale"] = "Launch the kettle.", + ["sources"] = sources.Length == 0 ? null : new JsonArray([.. sources.Select(source => JsonValue.Create(source))]), + }); + } + + private static async Task FinishedAsync(ConnectedAgent agent, string setId) + { + var deadline = DateTime.UtcNow.AddSeconds(60); + while (true) + { + var read = await agent.CallAsync("get_change_set_checks", new() { ["id"] = setId }); + if ((string?)read["job"]?["state"] is "succeeded" or "failed" or "dead" || DateTime.UtcNow > deadline) + { + return read; + } + + await Task.Delay(100); + } + } + + // An LLM-judged type that asks no model: it fails content with "!!!" and records whom it was charged to and as whom it ran. + private sealed class RecordingJudge : ICheckType + { + public const string Name = "sample.judge"; + + public static ConcurrentQueue<(Guid? ChargedTo, Initiator? RanAs)> Calls { get; } = new(); + + string ICheckType.Name => Name; + + public CheckKind Kind => CheckKind.LlmJudged; + + public IReadOnlyList ValidateSettings(JsonElement settings) + { + return new CheckSettingsReader(Name, settings).Errors(); + } + + public Task EvaluateAsync(CheckInput input, CancellationToken cancellationToken) + { + Calls.Enqueue((input.ChargedTo, input.Services.GetRequiredService().Current)); + var loud = input.Subject.Fields.ValueKind == JsonValueKind.Object + && input.Subject.Fields.EnumerateObject().Any(field => field.Value.ValueKind == JsonValueKind.String && field.Value.GetString()!.Contains("!!!")); + + return Task.FromResult(loud ? CheckOutcome.Fail("Drop the exclamation marks.", "/title") : CheckOutcome.Pass()); + } + } +} diff --git a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs index 6c322813d..23bc769ac 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs @@ -1,8 +1,10 @@ using System.Text.Json.Nodes; using FluentCMS.Core.Agents; +using FluentCMS.Core.Identity; using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -25,11 +27,16 @@ public static async Task StartAsync( string provider, int itemsPerChangeSet = 200, IReadOnlyList? types = null, - AgentOperation[]? operations = null) + AgentOperation[]? operations = null, + IDictionary? settings = null, + Action? configure = 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())), + configure: configure); var administrator = await host.CreateFirstAdministratorAsync(); var issued = await McpAgents.IssueAsync(host, administrator, new NewAgentCredential( "schema-agent", @@ -60,6 +67,20 @@ public async Task AsPersonAsync(Func>(administrator, action)).Value; } + public Task AsPersonAsync(Func> action) + where TService : notnull + { + return host.AsUserAsync(administrator, action); + } + + // As the check engine, for what only it does (ADR-0012). + public async Task AsEngineAsync(Func> action) + where TService : notnull + { + await using var scope = host.App.Services.GetRequiredService().CreateScopeActingAs(SystemInitiator.Checks); + return await action(scope.ServiceProvider.GetRequiredService()); + } + 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 2866d77e3..cdbd4234c 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs @@ -21,11 +21,11 @@ internal static class McpAgents // The built-in tools every server lists, sorted by name. public static readonly string[] BuiltInTools = [ - "check_draft", "create_content_type", "create_entry", "delete_entry", "get_blast_radius", "get_change_set", "get_change_set_diff", - "get_content_type", "get_entity_facts", "get_entity_relations", "get_entry", "get_entry_history", "get_resolved_entry", - "get_schema_document", "get_where_used", "list_content_types", "list_entries", "list_stale_facts", "list_unsourced_facts", - "open_change_set", "relate_entities", "reopen_change_set", "revise_content_type", "set_entity_fact", "submit_change_set", - "update_entry", + "check_draft", "create_content_type", "create_entry", "delete_entry", "get_blast_radius", "get_change_set", "get_change_set_checks", + "get_change_set_diff", "get_content_type", "get_entity_facts", "get_entity_relations", "get_entry", "get_entry_history", + "get_resolved_entry", "get_schema_document", "get_where_used", "list_content_types", "list_entries", "list_stale_facts", + "list_unsourced_facts", "open_change_set", "relate_entities", "reopen_change_set", "revise_content_type", "set_entity_fact", + "submit_change_set", "update_entry", ]; public static async Task ToolNamesAsync(McpClient client) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs index cb109ac24..ed364876c 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/WriteToolsTests.cs @@ -46,11 +46,12 @@ public async Task An_agent_proposes_a_new_type_and_a_revision_in_one_set_and_sub Assert.Equal(1, (int)created["contentType"]!["version"]!); Assert.Equal(2, (int)revised["contentType"]!["version"]!); Assert.Contains("submit_change_set", (string?)revised["next"]); - Assert.Equal("checks_running", (string?)submitted["changeSet"]!["state"]); + // No check reads a content type change, so the set passes its checks at once and waits for review. + Assert.Equal("awaiting_review", (string?)submitted["changeSet"]!["state"]); var stored = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse(id))); var category = await agent.AsPersonAsync(directory => directory.GetAsync("category")); - Assert.Equal(ChangeSetState.ChecksRunning, stored.State); + Assert.Equal(ChangeSetState.AwaitingReview, stored.State); Assert.Equal(agent.Credential.Initiator, stored.Initiator); Assert.Equal( [ diff --git a/tests/FluentCMS.IntegrationTests/TestServices.cs b/tests/FluentCMS.IntegrationTests/TestServices.cs index cac99f2f8..3b61141ab 100644 --- a/tests/FluentCMS.IntegrationTests/TestServices.cs +++ b/tests/FluentCMS.IntegrationTests/TestServices.cs @@ -1,4 +1,5 @@ using FluentCMS.Core; +using FluentCMS.Core.Identity; using FluentCMS.Infrastructure; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -20,6 +21,12 @@ public static ServiceProvider Build( services.AddLogging(); // Test doubles go in first so a test clock wins over the TimeProvider Infrastructure adds with TryAdd. configure?.Invoke(services); + + // As the hosts do, so a scope can act as an engine (ADR-0012). + if (services.Any(descriptor => descriptor.ServiceType == typeof(ICurrentInitiator))) + { + services.AddActingInitiator(); + } services.AddFluentCmsCore(); services.AddFluentCmsInfrastructure(configuration); diff --git a/tests/FluentCMS.Tests/Checks/CheckRunJobTests.cs b/tests/FluentCMS.Tests/Checks/CheckRunJobTests.cs new file mode 100644 index 000000000..0625360ac --- /dev/null +++ b/tests/FluentCMS.Tests/Checks/CheckRunJobTests.cs @@ -0,0 +1,147 @@ +using System.Text.Json; +using FluentCMS.Core; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Checks; + +// The check run job runs as the check engine, retries what another attempt can fix and, on its last attempt, fails the set rather +// than leave it in checks running for good (CHK-03, ADR-0012). +public sealed class CheckRunJobTests : IDisposable +{ + private static readonly Guid SetId = Guid.NewGuid(); + + private readonly FakeChangeSetChecks _checks = new(); + private readonly ServiceProvider _provider; + + public CheckRunJobTests() + { + _provider = new ServiceCollection().AddFluentCmsCore().AddSingleton(_checks).BuildServiceProvider(); + } + + private IJobHandler Handler => _provider.GetServices().Single(handler => handler.Type == "checks.run"); + + [Fact] + public async Task It_runs_as_the_check_engine_for_its_own_submission_and_reports_progress() + { + var job = Job(attempts: 1); + var reported = new List(); + + var result = await Handler.RunAsync(Context(job, reported), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(SystemInitiator.Checks, Handler.RunAs); + Assert.Equal((SetId, job.Id, CheckRunMode.Job), _checks.Ran.Single()); + Assert.Equal("Checked 1 of 2 items.", Assert.Single(reported).Message); + } + + [Fact] + public async Task A_payload_that_names_no_set_fails_the_job() + { + var result = await Handler.RunAsync(Context(Job(attempts: 1, payload: """{"changeSetId": "not a guid"}""")), CancellationToken.None); + + Assert.Equal("payload_invalid", Assert.Single(result.Errors).Code); + Assert.Empty(_checks.Ran); + } + + [Fact] + public async Task A_write_that_met_the_run_is_retried() + { + _checks.Run = () => Result.Failure(ChangeSetErrorCodes.Conflict, "The set changed."); + + await Assert.ThrowsAsync(() => Handler.RunAsync(Context(Job(attempts: 1)), CancellationToken.None)); + Assert.Empty(_checks.Abandoned); + } + + [Fact] + public async Task A_fault_before_the_last_attempt_is_retried_and_on_the_last_one_fails_the_set() + { + _checks.Run = () => throw new InvalidOperationException("The database is unreachable."); + + await Assert.ThrowsAsync(() => Handler.RunAsync(Context(Job(attempts: 5)), CancellationToken.None)); + var last = Job(attempts: 6); + var result = await Handler.RunAsync(Context(last), CancellationToken.None); + + Assert.Equal(("check_run_abandoned", "The database is unreachable."), (Assert.Single(result.Errors).Code, result.Errors[0].Message)); + Assert.Equal((SetId, last.Id), _checks.Abandoned.Single()); + } + + [Fact] + public async Task A_lost_lease_ends_the_attempt_without_failing_the_set() + { + _checks.Run = () => Result.Failure("check_run_lease_lost", "Another worker has the job."); + + var result = await Handler.RunAsync(Context(Job(attempts: 6)), CancellationToken.None); + + Assert.Equal("check_run_lease_lost", Assert.Single(result.Errors).Code); + Assert.Empty(_checks.Abandoned); + } + + public void Dispose() + { + _provider.Dispose(); + } + + private static Job Job(int attempts, string? payload = null) + { + return new Job + { + Id = Guid.CreateVersion7(), + Type = "checks.run", + Payload = JsonSerializer.Deserialize(payload ?? JsonSerializer.Serialize(new { changeSetId = SetId })), + State = JobState.Running, + Attempts = attempts, + MaxAttempts = 6, + CreatedAt = DateTimeOffset.UtcNow, + ScheduledAt = DateTimeOffset.UtcNow, + }; + } + + private JobContext Context(Job job, List? reported = null) + { + return new JobContext(job, _provider, (progress, _) => + { + reported?.Add(progress); + return Task.FromResult(true); + }); + } + + private sealed class FakeChangeSetChecks : IChangeSetChecks + { + public Func> Run { get; set; } = () => Result.Success(null); + + public List<(Guid ChangeSetId, Guid JobId, CheckRunMode Mode)> Ran { get; } = []; + + public List<(Guid ChangeSetId, Guid JobId)> Abandoned { get; } = []; + + public async Task> RunAsync( + Guid changeSetId, + Guid jobId, + CheckRunMode mode, + Func>? progress = null, + CancellationToken cancellationToken = default) + { + Ran.Add((changeSetId, jobId, mode)); + if (progress is not null) + { + await progress(1, 2); + } + + return Run(); + } + + public Task> AbandonAsync(Guid changeSetId, Guid jobId, CheckRunMode mode, CancellationToken cancellationToken = default) + { + Abandoned.Add((changeSetId, jobId)); + return Task.FromResult(Result.Success(null)); + } + + public Task> SubmitAsync(Guid changeSetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> GetAsync(Guid changeSetId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +} diff --git a/tests/FluentCMS.Tests/Checks/CheckRunnerTests.cs b/tests/FluentCMS.Tests/Checks/CheckRunnerTests.cs index 504852fed..95980cb08 100644 --- a/tests/FluentCMS.Tests/Checks/CheckRunnerTests.cs +++ b/tests/FluentCMS.Tests/Checks/CheckRunnerTests.cs @@ -81,6 +81,18 @@ public async Task Deterministic_checks_alone_ask_no_model() Assert.Empty(_gateway.Requests); } + // SEC-04: a run the check engine makes for an agent's change set spends that agent's model budget, not the engine's. + [Fact] + public async Task An_LLM_judged_check_charges_its_model_call_to_the_agent_the_run_is_for() + { + var agent = Guid.NewGuid(); + + await Runner.RunAsync(Subject("product-page", """{"headline": "A kettle", "body": "Calm."}"""), chargedTo: agent); + await Runner.RunAsync(Subject("product-page", """{"headline": "A kettle", "body": "Calm."}""")); + + Assert.Equal([agent, null], _gateway.Requests.Select(request => request.ChargedTo)); + } + // MDL-03: without a model an LLM-judged check needs review, which a pass elsewhere does not hide. [Fact] public async Task Without_a_model_the_run_needs_review_unless_something_fails() diff --git a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs index f191a0da9..5d2b8ed01 100644 --- a/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs +++ b/tests/FluentCMS.Tests/Tools/McpCoverageTests.cs @@ -33,7 +33,7 @@ public sealed class McpCoverageTests new("ENT", "List stale facts", ["list_stale_facts"]), Missing("RUL", "Read and propose rules", "#2419"), new("CHK", "Run the checks on a draft before proposing it", ["check_draft"]), - Missing("CHK", "Read the check results of a change set", "#2420"), + new("CHK", "Read the check results of a change set", ["get_change_set_checks"]), Missing("DSK", "Read review decisions, read-only", "#2422"), Missing("MED", "Upload and describe media", "#2427"), Missing("Search", "Search entries and entities", "#2430"), From 5a21073ef6c23b5287a8538c4d1eaa0a0502fece Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 00:59:16 -0400 Subject: [PATCH 2/5] Address review: reverts, stuck sets and engine scopes --- CONTRIBUTING.md | 2 +- .../Builtin/Tools/ChangeSetTools.cs | 12 +- src/FluentCMS.Core/Builtin/Tools/ToolJson.cs | 7 +- .../Checks/ChangeSetCheckRun.cs | 10 +- src/FluentCMS.Core/Checks/ChangeSetChecks.cs | 77 +++++++++--- .../Identity/ActingInitiator.cs | 46 ------- src/FluentCMS.Core/Identity/EngineScopes.cs | 45 +++++++ src/FluentCMS.Host/FluentCmsHost.cs | 14 ++- src/FluentCMS.Host/Jobs/JobQueueWorker.cs | 3 +- src/FluentCMS.Host/Jobs/JobRun.cs | 16 +++ .../Checks/CheckRunRow.cs | 3 + .../Checks/CheckRuns.cs | 3 +- ...s => 20261019043617_CheckRuns.Designer.cs} | 5 +- ...eckRuns.cs => 20261019043617_CheckRuns.cs} | 1 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 3 + ...s => 20261019043548_CheckRuns.Designer.cs} | 5 +- ...eckRuns.cs => 20261019043548_CheckRuns.cs} | 1 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 3 + .../Mcp/ChangeSetChecksTests.cs | 116 ++++++++++++++++-- .../Mcp/ConnectedAgent.cs | 9 -- .../TestServices.cs | 2 +- 21 files changed, 271 insertions(+), 112 deletions(-) delete mode 100644 src/FluentCMS.Core/Identity/ActingInitiator.cs create mode 100644 src/FluentCMS.Core/Identity/EngineScopes.cs create mode 100644 src/FluentCMS.Host/Jobs/JobRun.cs rename src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/{20261019035255_CheckRuns.Designer.cs => 20261019043617_CheckRuns.Designer.cs} (99%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/{20261019035255_CheckRuns.cs => 20261019043617_CheckRuns.cs} (98%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/{20261019035242_CheckRuns.Designer.cs => 20261019043548_CheckRuns.Designer.cs} (99%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/{20261019035242_CheckRuns.cs => 20261019043548_CheckRuns.cs} (98%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 844d93bb8..bcab1cb7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -135,7 +135,7 @@ An LLM-judged type asks a model only through `IModelGateway`, which it reads fro A check is a versioned definition under a slug: a check type and its settings, a description of what it enforces, a severity (block or warn), an optional scope of content types and fields, and test examples, at least one it must pass and one it must fail. `ICheckDirectory` activates a version only after running its examples, and names each example that does not get its verdict (CHK-02). A revision leaves the active version in place until the new one is activated, and only a person activates or deactivates a check. `ICheckRunner` runs the active checks that apply to a piece of content and gives each one's status: pass, warn for a failing check of warn severity, fail, or needs review. Agents run it on a draft with the `check_draft` tool before they propose it (CHK-04). -A submitted change set is checked as the check engine (`SystemInitiator.Checks`, ADR-0012), never as the agent: submit it through `IChangeSetChecks.SubmitAsync`, not by moving it to checks running yourself. A set of up to 50 operations that no active LLM-judged check reads is checked on the request that submits it; any other set is checked by a `checks.run` job, which reports its progress (PERF-02). Every entry and entity the set creates or updates is checked at the version it proposes, and an LLM-judged check charges its model call to the agent that opened the set (SEC-04). An item the engine cannot read, such as an entry deleted since, is a problem that fails the run, as is a job whose last attempt throws, so a set never stays in checks running. Each run belongs to its submission's job, and a run of an earlier submission changes nothing. The run, each item's results with their findings and model calls, and the move it decides (checks failed when a check fails, otherwise awaiting review) commit together, numbered per set, with the audit events `change_set.checked` and `check.judged` (CHK-03, CHK-05). Agents read the latest run and its job with the `get_change_set_checks` tool. +A submitted change set is checked as the check engine (`SystemInitiator.Checks`, ADR-0012), never as the agent: submit it through `IChangeSetChecks.SubmitAsync`, not by moving it to checks running yourself. While every active check is deterministic, a set of up to 50 operations is checked on the request that submits it; any other set is checked by a `checks.run` job, which reports its progress (PERF-02). Every entry and entity the set creates or updates is checked at the version it proposes, and an LLM-judged check charges its model call to the agent that opened the set (SEC-04). An item the engine cannot read, such as an entry deleted since, is a problem that fails the run. So is a job whose last attempt throws, and a set whose job ended without moving it is failed when it is submitted again, so it can be reopened. Each run names its submission's job, and a run of an earlier submission changes nothing. A revert is never held by its checks: it goes to review with their results (CHG-06). The run, each item's results with their findings and model calls, and the move it decides (checks failed when a check fails, otherwise awaiting review) commit together, numbered per set, with the audit events `change_set.checked` and `check.judged` (CHK-03, CHK-05). Agents read the run of the latest submission and its job with the `get_change_set_checks` tool, which shows a job's state and progress but not the error a worker recorded. ## Code style diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs index 7bce4fb20..fd8bdbc2f 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs @@ -50,7 +50,7 @@ public sealed class ChangeSetTools : IToolProvider "Submit a change set for checks and review", "Submits one of your open change sets once it holds every operation it needs: the site's checks run on it, and a person " + "reviews it before anything is published. A small set is checked at once and the answer holds its check results; a " - + "larger one, or one an LLM-judged check reads, is checked in the background, so follow it with get_change_set_checks. " + + "larger one, or any set while an LLM-judged check is active, is checked in the background, so follow it with get_change_set_checks. " + "A submitted set takes no more operations. An agent never approves or publishes a set; if checks fail, reopen it with " + "reopen_change_set, fix what they found and submit it again.", ToolSchemas.Object(ToolSchemas.Uuid("id", "The id of your open change set.")), @@ -63,7 +63,7 @@ public sealed class ChangeSetTools : IToolProvider + "Read the set with get_change_set first.", ToolSchemas.Object(ToolSchemas.Uuid("id", "The id of your change set whose checks failed.")), readOnly: false, - (call, cancellationToken) => MoveAsync(call, ChangeSetState.Open, cancellationToken)), + ReopenAsync), ]; private static async Task OpenAsync(ToolInvocation call, CancellationToken cancellationToken) @@ -116,7 +116,7 @@ private static async Task SubmitAsync(ToolInvocation call, Cancellat }); } - private static async Task MoveAsync(ToolInvocation call, ChangeSetState target, CancellationToken cancellationToken) + private static async Task ReopenAsync(ToolInvocation call, CancellationToken cancellationToken) { var arguments = new ToolArgumentsReader(call.Arguments); var id = arguments.RequiredGuid("id"); @@ -125,15 +125,13 @@ private static async Task MoveAsync(ToolInvocation call, ChangeSetSt return ToolResult.Failure(errors); } - var set = await call.Services.GetRequiredService().TransitionAsync(id!.Value, target, cancellationToken); + var set = await call.Services.GetRequiredService().TransitionAsync(id!.Value, ChangeSetState.Open, cancellationToken); return ToolResult.From( set, moved => new JsonObject { ["changeSet"] = ToolJson.ChangeSet(moved), - ["next"] = target == ChangeSetState.Open - ? "The set is open again: add the operations that fix it, then call submit_change_set." - : "Checks run next, and a person reviews the set. Follow its state with get_change_set.", + ["next"] = "The set is open again: add the operations that fix it, then call submit_change_set.", }, Hint(set)); } diff --git a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs index f2f21b614..85806c136 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -1,13 +1,12 @@ using System.Text.Json; using System.Text.Json.Nodes; using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; using FluentCMS.Core.Content; using FluentCMS.Core.Entities; using FluentCMS.Core.Identity; -using FluentCMS.Core.Schema; - -using FluentCMS.Core.Checks; using FluentCMS.Core.Jobs; +using FluentCMS.Core.Schema; namespace FluentCMS.Core.Builtin.Tools; @@ -76,6 +75,7 @@ public static JsonObject CheckRun(ChangeSetCheckRun run) }; } + // Not the error a worker recorded, which can quote database or provider details; a failed run says what to do in its problems. public static JsonObject Job(Job job) { return new JsonObject @@ -85,7 +85,6 @@ public static JsonObject Job(Job job) ["done"] = job.Progress.Done, ["total"] = job.Progress.Total, ["message"] = job.Progress.Message, - ["lastError"] = job.LastError, }; } diff --git a/src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs b/src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs index 09a2dfb21..08ff4deab 100644 --- a/src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs +++ b/src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs @@ -17,10 +17,11 @@ public sealed record ItemChecks(ItemKind ItemKind, Guid ItemId, int Version, str // One run of the checks on a change set, stored with the set (CHK-03): its results with their evidence, the overall status and what // its model calls cost. Problems say what kept the engine from checking an item, or the set, and fail the run. A set reopened and -// submitted again runs again, so runs are numbered from 1. +// submitted again runs again, so runs are numbered from 1, and each names the job of the submission it checked. public sealed record ChangeSetCheckRun( Guid Id, Guid ChangeSetId, + Guid JobId, int Number, CheckRunMode Mode, DateTimeOffset StartedAt, @@ -33,7 +34,7 @@ public sealed record ChangeSetCheckRun( // A submitted set, with its check run when it ran inline and the job that runs or backs it. public sealed record ChangeSetSubmission(ChangeSet ChangeSet, ChangeSetCheckRun? Run, Job Job); -// A set's latest check run, null before one finishes, and its latest check job, which shows a run's progress. +// A set's check run for its latest submission, null until that run is done, and the submission's job, which shows its progress. public sealed record ChangeSetChecksState(ChangeSet ChangeSet, ChangeSetCheckRun? Run, Job? Job); // The check runs of change sets, which Infrastructure stores. @@ -48,8 +49,9 @@ public interface ICheckRuns // Checks run on every change set before routing (CHK-03). A set is submitted through here, so the checks are sure to run on it. public interface IChangeSetChecks { - // Moves an open set to checks running and enqueues its check run with it. A small set whose active checks are all deterministic - // is then checked at once (PERF-02); its job only waits in case that run did not finish. Anything else runs as the job. + // Moves an open set to checks running and enqueues its check run with it. A small set is checked at once while every active + // check is deterministic (PERF-02); its job only waits in case that run did not finish. Anything else runs as the job. A set + // whose job ended without moving it is failed instead, so it can be reopened. Task> SubmitAsync(Guid changeSetId, CancellationToken cancellationToken = default); // Runs the active checks on every entry and entity the set proposes, stores the results and moves the set: to checks failed if diff --git a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs index 1907a4c4c..8d465331d 100644 --- a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs +++ b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs @@ -26,10 +26,11 @@ internal sealed class ChangeSetChecks( IUnitOfWork unitOfWork, IAuditLog auditLog, IServiceScopeFactory scopes, + ICurrentInitiator currentInitiator, TimeProvider clock) : IChangeSetChecks { // The most items a set may have to be checked on the request that submits it, which keeps that request within PERF-02's ten - // seconds; larger sets, and any set an LLM-judged check reads, are checked as a job (ADR-0010). + // seconds; larger sets, and every set while an LLM-judged check is active, are checked as a job (ADR-0010). public const int InlineItems = 50; public const string JobType = "checks.run"; @@ -54,6 +55,20 @@ public async Task> SubmitAsync(Guid changeSetId, Can return Result.Failure(read.Errors); } + // A set whose check job ended without moving it, as when the host stopped during its last attempt, is failed now, so the + // agent can reopen it and submit it again. Only whoever may submit the set gets that far. + if (read.Value.State == ChangeSetState.ChecksRunning + && MaySubmit(read.Value) + && await LatestJobAsync(changeSetId, cancellationToken) is { State: JobState.Succeeded or JobState.Failed or JobState.Dead } ended) + { + await using var recovery = scopes.CreateEngineScope(SystemInitiator.Checks); + var abandoned = await recovery.ServiceProvider.GetRequiredService().AbandonAsync(changeSetId, ended.Id, CheckRunMode.Job, cancellationToken); + var recovered = await changeSets.GetAsync(changeSetId, cancellationToken); + return abandoned.IsSuccess && recovered.IsSuccess + ? Result.Success(new ChangeSetSubmission(recovered.Value, abandoned.Value, ended)) + : Result.Failure(abandoned.IsSuccess ? recovered.Errors : abandoned.Errors); + } + var inline = read.Value.Operations.Count <= InlineItems && !await AnyJudgedAsync(cancellationToken); Job job; @@ -90,17 +105,22 @@ public async Task> SubmitAsync(Guid changeSetId, Can return Result.Success(new ChangeSetSubmission(submitted, null, job)); } - // The set is submitted now whatever becomes of the request, so the run is not cancelled with it; if it does not finish, the - // job checks the set instead. - await using var engine = scopes.CreateScopeActingAs(SystemInitiator.Checks); - var ran = await engine.ServiceProvider.GetRequiredService() - .RunAsync(changeSetId, job.Id, CheckRunMode.Inline, null, CancellationToken.None); + // The set is submitted now whatever becomes of the request, so the run is not cancelled with it, and a run that fails is + // left to the job, which checks the set instead and keeps the error if it fails again. + ChangeSetCheckRun? run = null; + try + { + await using var engine = scopes.CreateEngineScope(SystemInitiator.Checks); + var ran = await engine.ServiceProvider.GetRequiredService() + .RunAsync(changeSetId, job.Id, CheckRunMode.Inline, null, CancellationToken.None); + run = ran.IsSuccess ? ran.Value : null; + } + catch (Exception) + { + } var after = await changeSets.GetAsync(changeSetId, CancellationToken.None); - return Result.Success(new ChangeSetSubmission( - after.IsSuccess ? after.Value : submitted, - ran.IsSuccess ? ran.Value : null, - job)); + return Result.Success(new ChangeSetSubmission(after.IsSuccess ? after.Value : submitted, run, job)); } public async Task> RunAsync( @@ -153,6 +173,7 @@ public async Task> SubmitAsync(Guid changeSetId, Can var run = new ChangeSetCheckRun( Guid.CreateVersion7(), changeSetId, + jobId, 0, mode, started, @@ -162,22 +183,24 @@ public async Task> SubmitAsync(Guid changeSetId, Can items, problems); - return await FinishAsync(run, jobId, cancellationToken); + return await FinishAsync(set, run, cancellationToken); } public async Task> AbandonAsync(Guid changeSetId, Guid jobId, CheckRunMode mode, CancellationToken cancellationToken = default) { var current = await CurrentAsync(changeSetId, jobId, cancellationToken); - if (!current.IsSuccess || current.Value is null) + if (!current.IsSuccess || current.Value is not { } set) { return current.IsSuccess ? Result.Success(await runs.LatestAsync(changeSetId, cancellationToken)) : Result.Failure(current.Errors); } var now = clock.GetUtcNow(); return await FinishAsync( + set, new ChangeSetCheckRun( Guid.CreateVersion7(), changeSetId, + jobId, 0, mode, now, @@ -186,20 +209,22 @@ public async Task> SubmitAsync(Guid changeSetId, Can 0, [], [$"The checks could not run on this change set. Reopen it and submit it again; if they still cannot run, ask a person to look at job {jobId}."]), - jobId, cancellationToken); } public async Task> GetAsync(Guid changeSetId, CancellationToken cancellationToken = default) { + // The job first: a run moves its set before its job ends, so a job read as ended comes with the set it moved. + var job = await LatestJobAsync(changeSetId, cancellationToken); var read = await changeSets.GetAsync(changeSetId, cancellationToken); if (!read.IsSuccess) { return Result.Failure(read.Errors); } + // The run of an earlier submission says nothing about this one, so a set submitted again shows none until its run is done. var run = await runs.LatestAsync(changeSetId, cancellationToken); - return Result.Success(new ChangeSetChecksState(read.Value, run, await LatestJobAsync(changeSetId, cancellationToken))); + return Result.Success(new ChangeSetChecksState(read.Value, run?.JobId == job?.Id ? run : null, job)); } // The set when this job's submission is the one in checks running, and null when the set has moved on or been submitted again. @@ -217,7 +242,7 @@ public async Task> GetAsync(Guid changeSetId, Cance // Records the run and makes the move it decides in one unit of work, which commits only if the run's submission is still the // set's: the job is checked after the move, whose row lock makes a submission that commits first visible to it. - private async Task> FinishAsync(ChangeSetCheckRun run, Guid jobId, CancellationToken cancellationToken) + private async Task> FinishAsync(ChangeSet set, ChangeSetCheckRun run, CancellationToken cancellationToken) { var changeSetId = run.ChangeSetId; Result moved; @@ -242,10 +267,12 @@ await auditLog.AppendAsync( cancellationToken); } - // Routing decides what happens to a set that passes (F-RTE); until it does, every set that passes waits for review. - var target = run.Status == CheckStatus.Fail ? ChangeSetState.ChecksFailed : ChangeSetState.AwaitingReview; + // Routing decides what happens to a set that passes (F-RTE); until it does, every set that passes waits for review. A + // revert restores what was published before and takes no new operations, so its checks inform the person who reviews + // it rather than hold it (CHG-06). + var target = run.Status == CheckStatus.Fail && set.RevertsChangeSetId is null ? ChangeSetState.ChecksFailed : ChangeSetState.AwaitingReview; moved = await changeSets.TransitionAsync(changeSetId, target, cancellationToken); - if (moved.IsSuccess && (await LatestJobAsync(changeSetId, cancellationToken))?.Id == jobId) + if (moved.IsSuccess && (await LatestJobAsync(changeSetId, cancellationToken))?.Id == run.JobId) { await work.CommitAsync(cancellationToken); committed = true; @@ -259,7 +286,7 @@ await auditLog.AppendAsync( } // Rolled back. A set still in checks running for this submission met a write that another attempt can get past. - var current = await CurrentAsync(changeSetId, jobId, cancellationToken); + var current = await CurrentAsync(changeSetId, run.JobId, cancellationToken); if (current.IsSuccess && current.Value is not null) { return Result.Failure(moved.IsSuccess ? [new Error(ChangeSetErrorCodes.Conflict, $"Change set {changeSetId} changed during its check run.")] : moved.Errors); @@ -268,6 +295,16 @@ await auditLog.AppendAsync( return Result.Success(await runs.LatestAsync(changeSetId, cancellationToken)); } + private bool MaySubmit(ChangeSet set) + { + return currentInitiator.Current switch + { + UserInitiator => true, + AgentInitiator agent => set.Initiator is AgentInitiator owner && owner.CredentialId == agent.CredentialId, + _ => false, + }; + } + private async Task LatestJobAsync(Guid changeSetId, CancellationToken cancellationToken) { return (await jobs.ListForSubjectAsync(new JobSubject(SubjectType, changeSetId), cancellationToken)).FirstOrDefault(job => job.Type == JobType); @@ -333,7 +370,7 @@ private async Task AnyJudgedAsync(CancellationToken cancellationToken) return error is null ? null : $"The {operation.ItemKind.ToString().ToLowerInvariant()} {operation.ItemId} could not be checked: {error.Message} " - + "Restore it, or reopen the change set and remove its operation."; + + "A person can restore it; then reopen the change set and submit it again."; } private static List Changed(JsonElement proposed, JsonElement before) diff --git a/src/FluentCMS.Core/Identity/ActingInitiator.cs b/src/FluentCMS.Core/Identity/ActingInitiator.cs deleted file mode 100644 index 089d2ef3f..000000000 --- a/src/FluentCMS.Core/Identity/ActingInitiator.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace FluentCMS.Core.Identity; - -// The initiator a scope acts as instead of the host's caller: a job's, or an engine's when the product does work on its own inside -// a request, such as the check run a submission starts (ADR-0012). Null leaves the host's caller. -public sealed class ActingInitiator -{ - public Initiator? Initiator { get; set; } -} - -public static class ActingInitiatorExtensions -{ - // Wraps the ICurrentInitiator a host registered last, so a scope can act as another initiator. A host calls it after - // registering its own. - public static IServiceCollection AddActingInitiator(this IServiceCollection services) - { - var registered = services.Last(descriptor => descriptor.ServiceType == typeof(ICurrentInitiator)); - services.Remove(registered); - services.AddScoped(); - services.AddScoped(provider => new ActingCurrentInitiator( - provider.GetRequiredService(), - registered.ImplementationInstance as ICurrentInitiator - ?? registered.ImplementationFactory?.Invoke(provider) as ICurrentInitiator - ?? (ICurrentInitiator)ActivatorUtilities.CreateInstance(provider, registered.ImplementationType!))); - - return services; - } - - // A scope of its own that acts as the initiator given, whoever the host's caller is. - public static AsyncServiceScope CreateScopeActingAs(this IServiceScopeFactory scopes, Initiator? initiator) - { - ArgumentNullException.ThrowIfNull(scopes); - - var scope = scopes.CreateAsyncScope(); - var acting = scope.ServiceProvider.GetService() - ?? throw new InvalidOperationException("The host does not let a scope act as another initiator; it calls AddActingInitiator."); - acting.Initiator = initiator; - return scope; - } - - private sealed class ActingCurrentInitiator(ActingInitiator acting, ICurrentInitiator host) : ICurrentInitiator - { - public Initiator? Current => acting.Initiator ?? host.Current; - } -} diff --git a/src/FluentCMS.Core/Identity/EngineScopes.cs b/src/FluentCMS.Core/Identity/EngineScopes.cs new file mode 100644 index 000000000..edf3855d5 --- /dev/null +++ b/src/FluentCMS.Core/Identity/EngineScopes.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Identity; + +public static class EngineScopes +{ + // Wraps the ICurrentInitiator a host registered last, so Core's engines can do their own work inside a request as themselves, + // such as the check run a submission starts (ADR-0012). A host calls it after registering its own. Only Core opens such a + // scope, and only as an engine, so no other code can act as someone it is not. + public static IServiceCollection AddEngineScopes(this IServiceCollection services) + { + var registered = services.Last(descriptor => descriptor.ServiceType == typeof(ICurrentInitiator)); + services.Remove(registered); + services.AddScoped(); + services.AddScoped(provider => new EngineAwareInitiator( + provider.GetRequiredService(), + registered.ImplementationInstance as ICurrentInitiator + ?? registered.ImplementationFactory?.Invoke(provider) as ICurrentInitiator + ?? (ICurrentInitiator)ActivatorUtilities.CreateInstance(provider, registered.ImplementationType!))); + + return services; + } + + internal static AsyncServiceScope CreateEngineScope(this IServiceScopeFactory scopes, SystemInitiator engine) + { + ArgumentNullException.ThrowIfNull(scopes); + ArgumentNullException.ThrowIfNull(engine); + + var scope = scopes.CreateAsyncScope(); + var acting = scope.ServiceProvider.GetService() + ?? throw new InvalidOperationException("The host does not let an engine act as itself inside a request; it calls AddEngineScopes."); + acting.Engine = engine; + return scope; + } + + private sealed class EngineScope + { + public SystemInitiator? Engine { get; set; } + } + + private sealed class EngineAwareInitiator(EngineScope scope, ICurrentInitiator host) : ICurrentInitiator + { + public Initiator? Current => scope.Engine ?? host.Current; + } +} diff --git a/src/FluentCMS.Host/FluentCmsHost.cs b/src/FluentCMS.Host/FluentCmsHost.cs index 4f28ba9a6..37249dbb4 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -172,11 +172,19 @@ public static IHost BuildMcpHost( return builder.Build(); } - // A job runs as whoever enqueued it, or as the engine its type names, whatever the host's own caller is, so the initiator each - // host registered is wrapped. + // A job runs as whoever enqueued it, whatever the host's own caller is, so the initiator each host registered is wrapped; so is + // the scope in which Core's engines work as themselves (ADR-0012). private static void RunJobsAsTheirInitiator(IServiceCollection services) { - services.AddActingInitiator(); + var registered = services.Last(descriptor => descriptor.ServiceType == typeof(ICurrentInitiator)); + services.Remove(registered); + services.AddScoped(); + services.AddScoped(provider => new JobAwareInitiator( + provider.GetRequiredService(), + registered.ImplementationInstance as ICurrentInitiator + ?? registered.ImplementationFactory?.Invoke(provider) as ICurrentInitiator + ?? (ICurrentInitiator)ActivatorUtilities.CreateInstance(provider, registered.ImplementationType!))); + services.AddEngineScopes(); } // One composition for both entry modes, so the stdio host opens the same database and runs the same services. diff --git a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs index a9a376690..94f307fcb 100644 --- a/src/FluentCMS.Host/Jobs/JobQueueWorker.cs +++ b/src/FluentCMS.Host/Jobs/JobQueueWorker.cs @@ -1,4 +1,3 @@ -using FluentCMS.Core.Identity; using FluentCMS.Core.Jobs; using Microsoft.Extensions.Options; @@ -72,7 +71,7 @@ private async Task RunNextAsync(string owner, JobQueueOptions settings, Ca return true; } - runScope.ServiceProvider.GetRequiredService().Initiator = handler.RunAs ?? job.Initiator; + runScope.ServiceProvider.GetRequiredService().Initiator = handler.RunAs ?? job.Initiator; var context = new JobContext( job, diff --git a/src/FluentCMS.Host/Jobs/JobRun.cs b/src/FluentCMS.Host/Jobs/JobRun.cs new file mode 100644 index 000000000..844576bcd --- /dev/null +++ b/src/FluentCMS.Host/Jobs/JobRun.cs @@ -0,0 +1,16 @@ +using FluentCMS.Core.Identity; + +namespace FluentCMS.Host.Jobs; + +// The job a scope runs, set by the worker alone. Its initiator is who enqueued the job, so the handler's changes name them. +internal sealed class JobRun +{ + public Initiator? Initiator { get; set; } +} + +// Wraps the host's own ICurrentInitiator, the request's or the stdio message's caller, so that inside a job's scope the job's +// initiator is the caller instead (AUD-01). +internal sealed class JobAwareInitiator(JobRun run, ICurrentInitiator host) : ICurrentInitiator +{ + public Initiator? Current => run.Initiator ?? host.Current; +} diff --git a/src/FluentCMS.Infrastructure/Checks/CheckRunRow.cs b/src/FluentCMS.Infrastructure/Checks/CheckRunRow.cs index 638cd69c4..f3cbeacbe 100644 --- a/src/FluentCMS.Infrastructure/Checks/CheckRunRow.cs +++ b/src/FluentCMS.Infrastructure/Checks/CheckRunRow.cs @@ -10,6 +10,9 @@ public sealed class CheckRunRow public Guid ChangeSetId { get; set; } + // The job of the submission the run checked, so a run of an earlier submission is told apart from the current one's. + public Guid JobId { get; set; } + public int Number { get; set; } public CheckRunMode Mode { get; set; } diff --git a/src/FluentCMS.Infrastructure/Checks/CheckRuns.cs b/src/FluentCMS.Infrastructure/Checks/CheckRuns.cs index c5afea21f..ab6f5b78c 100644 --- a/src/FluentCMS.Infrastructure/Checks/CheckRuns.cs +++ b/src/FluentCMS.Infrastructure/Checks/CheckRuns.cs @@ -26,6 +26,7 @@ public async Task RecordAsync(ChangeSetCheckRun run, Cancella { Id = numbered.Id, ChangeSetId = numbered.ChangeSetId, + JobId = numbered.JobId, Number = numbered.Number, Mode = numbered.Mode, StartedAt = numbered.StartedAt, @@ -93,7 +94,7 @@ public async Task RecordAsync(ChangeSetCheckRun run, Cancella .ToList(); var problems = JsonSerializer.Deserialize>(run.Problems) ?? []; - return new ChangeSetCheckRun(run.Id, run.ChangeSetId, run.Number, run.Mode, run.StartedAt, run.FinishedAt, run.Status, run.Cost, items, problems); + return new ChangeSetCheckRun(run.Id, run.ChangeSetId, run.JobId, run.Number, run.Mode, run.StartedAt, run.FinishedAt, run.Status, run.Cost, items, problems); } private static CheckResult ToResult(CheckResultRow row) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.Designer.cs similarity index 99% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.Designer.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.Designer.cs index e2609ba04..dce027797 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.Designer.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.Designer.cs @@ -13,7 +13,7 @@ namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql { [DbContext(typeof(PostgreSqlFluentCmsDbContext))] - [Migration("20261019035255_CheckRuns")] + [Migration("20261019043617_CheckRuns")] partial class CheckRuns { /// @@ -454,6 +454,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("FinishedAt") .HasColumnType("timestamp with time zone"); + b.Property("JobId") + .HasColumnType("uuid"); + b.Property("Mode") .IsRequired() .HasMaxLength(16) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.cs similarity index 98% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.cs index f38b8c23b..d57d16a4c 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019035255_CheckRuns.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.cs @@ -17,6 +17,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "uuid", nullable: false), ChangeSetId = table.Column(type: "uuid", nullable: false), + JobId = table.Column(type: "uuid", nullable: false), Number = table.Column(type: "integer", nullable: false), Mode = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), StartedAt = table.Column(type: "timestamp with time zone", nullable: false), diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index 6bc352bda..d61d873eb 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -451,6 +451,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FinishedAt") .HasColumnType("timestamp with time zone"); + b.Property("JobId") + .HasColumnType("uuid"); + b.Property("Mode") .IsRequired() .HasMaxLength(16) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.Designer.cs similarity index 99% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.Designer.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.Designer.cs index 381a5aedd..0958007a4 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.Designer.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.Designer.cs @@ -11,7 +11,7 @@ namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite { [DbContext(typeof(SqliteFluentCmsDbContext))] - [Migration("20261019035242_CheckRuns")] + [Migration("20261019043548_CheckRuns")] partial class CheckRuns { /// @@ -448,6 +448,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("FinishedAt") .HasColumnType("TEXT"); + b.Property("JobId") + .HasColumnType("TEXT"); + b.Property("Mode") .IsRequired() .HasMaxLength(16) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.cs similarity index 98% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.cs index 381f59430..4679a7bff 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019035242_CheckRuns.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.cs @@ -17,6 +17,7 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "TEXT", nullable: false), ChangeSetId = table.Column(type: "TEXT", nullable: false), + JobId = table.Column(type: "TEXT", nullable: false), Number = table.Column(type: "INTEGER", nullable: false), Mode = table.Column(type: "TEXT", maxLength: 16, nullable: false), StartedAt = table.Column(type: "TEXT", nullable: false), diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index cadbe483c..0ea77a9cc 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -445,6 +445,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FinishedAt") .HasColumnType("TEXT"); + b.Property("JobId") + .HasColumnType("TEXT"); + b.Property("Mode") .IsRequired() .HasMaxLength(16) diff --git a/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs index ff26ef820..34ec16780 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs @@ -1,10 +1,12 @@ using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Nodes; +using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Checks; using FluentCMS.Core.Content; using FluentCMS.Core.Extensions; using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; using FluentCMS.Core.Results; using FluentCMS.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; @@ -17,6 +19,9 @@ public sealed class ChangeSetChecksTests { private static readonly Dictionary FastQueue = new() { ["JobQueue:PollingInterval"] = "00:00:00.050" }; + // The worker polls once as the host starts, so a job enqueued later waits for the test. + private static readonly Dictionary IdleQueue = new() { ["JobQueue:PollingInterval"] = "01:00:00" }; + // S2: a set with "best in class", an image without alt text and copy that cites no source fails three checks that say what to change; // the fixed content passes. The checks, not the agent, move the set. [Theory] @@ -58,7 +63,7 @@ public async Task A_small_set_is_checked_when_it_is_submitted_and_the_check_engi } // A run belongs to its submission: the job of an earlier one, which may still fire, leaves a set reopened or submitted again - // as it is, and adds no run. + // as it is, and adds no run, even when a person, who may move any set, runs it. [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] @@ -72,14 +77,14 @@ public async Task The_job_of_an_earlier_submission_changes_nothing(string provid var earlierJob = Guid.Parse((string)earlier["id"]!); await agent.CallAsync("reopen_change_set", new() { ["id"] = setId.ToString() }); - var whileOpen = await agent.AsEngineAsync>(checks => checks.RunAsync(setId, earlierJob, CheckRunMode.Job)); + var whileOpen = await agent.AsPersonAsync(checks => checks.RunAsync(setId, earlierJob, CheckRunMode.Job)); var again = await agent.CallAsync("submit_change_set", new() { ["id"] = setId.ToString() }); - var afterwards = await agent.AsEngineAsync>(checks => checks.RunAsync(setId, earlierJob, CheckRunMode.Job)); - var abandoned = await agent.AsEngineAsync>(checks => checks.AbandonAsync(setId, earlierJob, CheckRunMode.Job)); + var afterwards = await agent.AsPersonAsync(checks => checks.RunAsync(setId, earlierJob, CheckRunMode.Job)); + var abandoned = await agent.AsPersonAsync(checks => checks.AbandonAsync(setId, earlierJob, CheckRunMode.Job)); - Assert.Equal(1, whileOpen.Value!.Number); + Assert.Equal(1, whileOpen!.Number); Assert.Equal(2, (int)again["checks"]!["number"]!); - Assert.Equal((2, 2), (afterwards.Value!.Number, abandoned.Value!.Number)); + Assert.Equal((2, 2), (afterwards!.Number, abandoned!.Number)); var read = await agent.CallAsync("get_change_set_checks", new() { ["id"] = setId.ToString() }); Assert.Equal(("checks_failed", 2), ((string?)read["changeSet"]!["state"], (int)read["checks"]!["number"]!)); Assert.NotEqual(earlierJob, Guid.Parse((string)read["job"]!["id"]!)); @@ -109,6 +114,65 @@ public async Task An_item_that_cannot_be_read_fails_the_set_with_a_problem(strin Assert.Equal((string?)kept["entry"]!["id"], (string?)Assert.Single(checks["items"]!.AsArray())!["itemId"]); } + // CHG-06: a revert restores what was published and takes no new operations, so a check that fails it, such as one asking for a + // source the revert cannot cite, informs its reviewer instead of holding it. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revert_goes_to_review_with_the_results_of_its_checks(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await ProductChecksAsync(agent); + var created = await SubmitAsync(agent, "launch", "A kettle", "exports/catalog.csv"); + await PublishAsync(agent, created); + var entryId = (string)(await agent.QueryAsync(context => context.Entries.AsNoTracking().Select(row => row.Id.ToString()).SingleAsync())); + var renamed = await agent.CallAsync("update_entry", new() + { + ["id"] = entryId, + ["base_version"] = 1, + ["fields"] = new JsonObject { ["title"] = "A steel kettle" }, + ["session_id"] = "rename", + ["rationale"] = "Name the material.", + ["sources"] = new JsonArray("exports/catalog.csv"), + }); + var published = await PublishAsync(agent, await agent.CallAsync("submit_change_set", new() { ["id"] = (string?)renamed["changeSet"]!["id"] })); + + var revert = await agent.AsPersonAsync(directory => directory.RevertAsync(published, "The rename was wrong.")); + var submitted = await agent.AsPersonAsync(checks => checks.SubmitAsync(revert.Id)); + + Assert.Equal(ChangeSetState.AwaitingReview, submitted.ChangeSet.State); + Assert.Equal(CheckStatus.Fail, submitted.Run!.Status); + var result = Assert.Single(submitted.Run.Items.Single().Results, result => result.Check == "sourced-copy"); + Assert.Equal((CheckStatus.Fail, 1), (result.Status, submitted.Run.Items.Single().Version)); + } + + // A set whose check job ended without moving it, as when the host stopped during the job's last attempt, is failed when the + // agent submits it again, so it can be reopened rather than stay in checks running. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_whose_check_job_ended_without_moving_it_is_failed_when_it_is_submitted_again(string provider) + { + await using var agent = await ConnectedAgent.StartAsync( + provider, + settings: IdleQueue, + configure: builder => builder.Services.AddFluentCmsExtension()); + await ProductChecksAsync(agent); + await ActivateAsync(agent, "calm", Definition(RecordingJudge.Name, "{}", "A kettle", "BUY!!!")); + var submitted = await SubmitAsync(agent, "stuck", "A kettle", "exports/catalog.csv"); + var setId = (string)submitted["changeSet"]!["id"]!; + var jobId = Guid.Parse((string)submitted["job"]!["id"]!); + await agent.QueryAsync(context => context.Jobs.Where(row => row.Id == jobId).ExecuteUpdateAsync(row => row.SetProperty(job => job.State, JobState.Dead))); + + var again = await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + + Assert.Equal("checks_running", (string?)submitted["changeSet"]!["state"]); + Assert.Equal("checks_failed", (string?)again["changeSet"]!["state"]); + Assert.Contains(jobId.ToString(), (string?)Assert.Single(again["checks"]!["problems"]!.AsArray())); + var reopened = await agent.CallAsync("reopen_change_set", new() { ["id"] = setId }); + Assert.Equal("open", (string?)reopened["changeSet"]!["state"]); + } + // A set with nothing to check passes and waits for review; routing decides more once it lands (F-RTE). [Theory] [InlineData(DatabaseProviders.Sqlite)] @@ -134,12 +198,14 @@ public async Task A_set_no_check_applies_to_waits_for_review(string provider) } // LLM-judged checks run as a job with progress (PERF-02), as the check engine, and their model calls count against the agent - // whose set they judge (SEC-04). - [Fact] - public async Task A_set_an_LLM_judged_check_reads_is_checked_as_a_job_charged_to_the_agent() + // whose set they judge (SEC-04). Each judgement names its model and prompt in the audit log (CHK-05). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_set_is_checked_as_a_job_while_an_LLM_judged_check_is_active_and_charged_to_the_agent(string provider) { await using var agent = await ConnectedAgent.StartAsync( - "sqlite", + provider, settings: FastQueue, configure: builder => builder.Services.AddFluentCmsExtension()); await ProductChecksAsync(agent); @@ -158,6 +224,14 @@ public async Task A_set_an_LLM_judged_check_reads_is_checked_as_a_job_charged_to var (chargedTo, ranAs) = RecordingJudge.Calls.Last(); Assert.Equal(agent.Credential.Id, chargedTo); Assert.Equal(SystemInitiator.Checks, ranAs); + + var judged = (string?)finished["checks"]!["items"]![0]!["results"]!.AsArray().Single(result => (string?)result!["check"] == "calm")!["model"]!["model"]; + Assert.Equal((RecordingJudge.Model, RecordingJudge.Cost), (judged, (decimal)finished["checks"]!["cost"]!)); + var events = await agent.QueryAsync(context => context.AuditEvents.AsNoTracking().Where(row => row.Operation == "check.judged").ToListAsync()); + var judgement = Assert.Single(events); + Assert.Equal( + (Guid.Parse(setId), RecordingJudge.Model, RecordingJudge.PromptHash, "system", "checks"), + (judgement.ChangeSetId!.Value, judgement.Model, judgement.PromptHash, judgement.InitiatorKind, judgement.InitiatorName)); } // PERF-02: deterministic checks over a 200-item set finish in under ten seconds; a set that large runs as a job. @@ -228,6 +302,16 @@ private static JsonElement Title(string title) return JsonSerializer.SerializeToElement(new { title }); } + // A person approves and publishes a set that passed its checks. + private static async Task PublishAsync(ConnectedAgent agent, JsonObject submitted) + { + Assert.Equal("awaiting_review", (string?)submitted["changeSet"]!["state"]); + var id = Guid.Parse((string)submitted["changeSet"]!["id"]!); + await agent.AsPersonAsync(directory => directory.TransitionAsync(id, ChangeSetState.Approved)); + await agent.AsPersonAsync(directory => directory.TransitionAsync(id, ChangeSetState.Published)); + return id; + } + private static async Task SubmitAsync(ConnectedAgent agent, string session, string title, params string[] sources) { var created = await CreateAsync(agent, session, title, sources); @@ -261,11 +345,18 @@ private static async Task FinishedAsync(ConnectedAgent agent, string } } - // An LLM-judged type that asks no model: it fails content with "!!!" and records whom it was charged to and as whom it ran. + // An LLM-judged type that asks no model: it fails content with "!!!", names a made-up model call, and records whom it was + // charged to and as whom it ran. private sealed class RecordingJudge : ICheckType { public const string Name = "sample.judge"; + public const string Model = "judge-1"; + + public const string PromptHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + public const decimal Cost = 0.25m; + public static ConcurrentQueue<(Guid? ChargedTo, Initiator? RanAs)> Calls { get; } = new(); string ICheckType.Name => Name; @@ -283,7 +374,8 @@ public Task EvaluateAsync(CheckInput input, CancellationToken canc var loud = input.Subject.Fields.ValueKind == JsonValueKind.Object && input.Subject.Fields.EnumerateObject().Any(field => field.Value.ValueKind == JsonValueKind.String && field.Value.GetString()!.Contains("!!!")); - return Task.FromResult(loud ? CheckOutcome.Fail("Drop the exclamation marks.", "/title") : CheckOutcome.Pass()); + var outcome = loud ? CheckOutcome.Fail("Drop the exclamation marks.", "/title") : CheckOutcome.Pass(); + return Task.FromResult(outcome.WithModel(new CheckModelUse(Guid.CreateVersion7(), "openai", Model, PromptHash, Cost))); } } } diff --git a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs index 23bc769ac..139fe2210 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs @@ -1,6 +1,5 @@ using System.Text.Json.Nodes; using FluentCMS.Core.Agents; -using FluentCMS.Core.Identity; using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Persistence; @@ -73,14 +72,6 @@ public async Task AsPersonAsync(Func(administrator, action); } - // As the check engine, for what only it does (ADR-0012). - public async Task AsEngineAsync(Func> action) - where TService : notnull - { - await using var scope = host.App.Services.GetRequiredService().CreateScopeActingAs(SystemInitiator.Checks); - return await action(scope.ServiceProvider.GetRequiredService()); - } - public Task CreateAsPersonAsync(string slug) { return AsPersonAsync(directory => diff --git a/tests/FluentCMS.IntegrationTests/TestServices.cs b/tests/FluentCMS.IntegrationTests/TestServices.cs index 3b61141ab..ccc051145 100644 --- a/tests/FluentCMS.IntegrationTests/TestServices.cs +++ b/tests/FluentCMS.IntegrationTests/TestServices.cs @@ -25,7 +25,7 @@ public static ServiceProvider Build( // As the hosts do, so a scope can act as an engine (ADR-0012). if (services.Any(descriptor => descriptor.ServiceType == typeof(ICurrentInitiator))) { - services.AddActingInitiator(); + services.AddEngineScopes(); } services.AddFluentCmsCore(); services.AddFluentCmsInfrastructure(configuration); From 4b137f10fb1c84772e0f7331eb60c86db3493af9 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 01:52:33 -0400 Subject: [PATCH 3/5] Let a reopened set take a newer draft of an item it holds --- .../Builtin/Tools/ChangeSetTarget.cs | 6 ++- .../Builtin/Tools/ChangeSetTools.cs | 10 ++-- .../Builtin/Tools/EntityTools.cs | 6 ++- .../Builtin/Tools/EntryTools.cs | 3 +- .../Builtin/Tools/ProposedEntryWrite.cs | 5 +- .../Builtin/Tools/ProposedWrite.cs | 2 +- src/FluentCMS.Core/ChangeSets/ChangeSet.cs | 31 ++++++++++-- src/FluentCMS.Core/Checks/ChangeSetChecks.cs | 21 +++++--- .../ChangeSets/ChangeSetDirectory.cs | 30 +++++++++++- .../Mcp/ChangeSetChecksTests.cs | 49 ++++++++++++++----- .../ChangeSets/ChangeSetTests.cs | 36 ++++++++++++++ 11 files changed, 163 insertions(+), 36 deletions(-) diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs index 94adf41e1..d62948c32 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTarget.cs @@ -84,7 +84,8 @@ changeSetId is not null // Refuses ahead of the write what the change set would refuse after it, so no version is stored for nothing and a refused // cap keeps its security event (SEC-08), which a rolled-back write would take with it. The set checks again on the add. - public async Task CheckAsync(IServiceProvider services, CancellationToken cancellationToken) + // An item the set already holds takes no more room, since its operation moves on to the newer version. + public async Task CheckAsync(IServiceProvider services, Guid? itemId, CancellationToken cancellationToken) { var directory = services.GetRequiredService(); var permissions = services.GetRequiredService(); @@ -95,7 +96,8 @@ public async Task CheckAsync(IServiceProvider services, CancellationToke if (set.IsSuccess) { - return await permissions.CheckChangeSetSizeAsync(set.Value, adding: 1, cancellationToken); + var adding = itemId is { } item && set.Value.Operations.Any(operation => operation.ItemId == item) ? 0 : 1; + return await permissions.CheckChangeSetSizeAsync(set.Value, adding, cancellationToken); } return _changeSetId is null ? await permissions.CheckNewChangeSetAsync(cancellationToken) : Result.Failure(set.Errors); diff --git a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs index fd8bdbc2f..c2128e21a 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ChangeSetTools.cs @@ -59,8 +59,9 @@ public sealed class ChangeSetTools : IToolProvider new AgentTool( "reopen_change_set", "Reopen a change set after failed checks", - "Reopens one of your change sets whose checks failed, so you can add operations that fix it and submit it again. " - + "Read the set with get_change_set first.", + "Reopens one of your change sets whose checks failed, so you can fix it and submit it again: update an item from the " + + "version the set proposes, with update_entry and the set's change_set_id, or add operations. Read what failed with " + + "get_change_set_checks first.", ToolSchemas.Object(ToolSchemas.Uuid("id", "The id of your change set whose checks failed.")), readOnly: false, ReopenAsync), @@ -109,7 +110,8 @@ private static async Task SubmitAsync(ToolInvocation call, Cancellat ["next"] = set.State switch { ChangeSetState.ChecksFailed => - "Checks failed: each failing result says what to change. Call reopen_change_set, fix the items and submit the set again.", + "Checks failed: each failing result and problem says what to change. Call reopen_change_set, update the items from the " + + "versions the set proposes and submit the set again.", ChangeSetState.AwaitingReview => "Checks passed, and a person reviews the set next. Follow its state with get_change_set.", _ => $"Checks run in the background as job {submission.Job.Id}. Follow them with get_change_set_checks.", }, @@ -131,7 +133,7 @@ private static async Task ReopenAsync(ToolInvocation call, Cancellat moved => new JsonObject { ["changeSet"] = ToolJson.ChangeSet(moved), - ["next"] = "The set is open again: add the operations that fix it, then call submit_change_set.", + ["next"] = "The set is open again: update its items from the versions it proposes, or add operations, then call submit_change_set.", }, Hint(set)); } diff --git a/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs b/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs index 8ea7d470b..7c021e5bd 100644 --- a/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/EntityTools.cs @@ -233,7 +233,8 @@ private static async Task SetFactAsync(ToolInvocation call, Cancella return Result.Success(); }, cancellationToken), ProposedEntryWrite.Update, - cancellationToken); + cancellationToken, + id); } private static async Task RelateAsync(ToolInvocation call, CancellationToken cancellationToken) @@ -290,7 +291,8 @@ private static async Task RelateAsync(ToolInvocation call, Cancellat return Result.Success(); }, cancellationToken), ProposedEntryWrite.Update, - cancellationToken); + cancellationToken, + id); } // Reads the entity at the version the agent read, changes its fields in a copy and writes the next version; a stale base version diff --git a/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs b/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs index c92f090bd..d10a7b60b 100644 --- a/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs +++ b/src/FluentCMS.Core/Builtin/Tools/EntryTools.cs @@ -242,7 +242,8 @@ private static async Task UpdateAsync(ToolInvocation call, Cancellat target!, entries => entries.UpdateAsync(id!.Value, baseVersion!.Value, fields!.Value, cancellationToken), ProposedEntryWrite.Update, - cancellationToken); + cancellationToken, + id); } private static async Task DeleteAsync(ToolInvocation call, CancellationToken cancellationToken) diff --git a/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs index 776ee22c2..9d9673c8f 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs @@ -23,10 +23,11 @@ public static async Task RunAsync( ChangeSetTarget target, Func>> write, Func> operation, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Guid? itemId = null) { var services = call.Services; - var ready = await target.CheckAsync(services, cancellationToken); + var ready = await target.CheckAsync(services, itemId, cancellationToken); if (!ready.IsSuccess) { return ToolResult.Failure(ready.Errors); diff --git a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs index ee086c0e3..40ebccbed 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ProposedWrite.cs @@ -20,7 +20,7 @@ public static async Task RunAsync( CancellationToken cancellationToken) { var services = call.Services; - var ready = await target.CheckAsync(services, cancellationToken); + var ready = await target.CheckAsync(services, null, cancellationToken); if (!ready.IsSuccess) { return ToolResult.Failure(ready.Errors); diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index ad64d8332..881953041 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -184,12 +184,23 @@ public Result AddOperation(ChangeOperation operation) $"This change set reverts change set {reverted} and holds exactly what undoes it, so no operation can be added."); } - // One operation per item keeps one diff per item and one version to restore on revert (CHG-03, CHG-06). - if (_operations.Any(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId)) + // One operation per item keeps one diff per item and one version to restore on revert (CHG-03, CHG-06). A newer version of an + // entry or entity the set already proposes, as when a reopened set is fixed, moves that operation on to it. + var position = _operations.FindIndex(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId); + if (position >= 0) { - return Result.Failure( - ChangeSetErrorCodes.ItemDuplicate, - $"The {Describe(operation.ItemKind)} {operation.ItemId} already has an operation in this change set."); + var existing = _operations[position]; + if (!Supersedes(existing, operation)) + { + return Result.Failure( + ChangeSetErrorCodes.ItemDuplicate, + existing.ProposedVersion is { } proposed && operation.ItemKind is ItemKind.Entry or ItemKind.Entity + ? $"The {Describe(operation.ItemKind)} {operation.ItemId} already has an operation in this change set; update it from version {proposed}, which the set proposes, to change what it proposes." + : $"The {Describe(operation.ItemKind)} {operation.ItemId} already has an operation in this change set."); + } + + _operations[position] = ChangeOperation.Restore(existing.Kind, existing.ItemKind, existing.ItemId, existing.BaseVersion, operation.ProposedVersion); + return Result.Success(); } _operations.Add(operation); @@ -286,6 +297,16 @@ private static string Describe(ChangeSetState state) }; } + // The same change from the same published version, proposing a later draft. + private static bool Supersedes(ChangeOperation existing, ChangeOperation operation) + { + return operation.ItemKind is ItemKind.Entry or ItemKind.Entity + && operation.Kind is OperationKind.Create or OperationKind.Update + && operation.Kind == existing.Kind + && operation.BaseVersion == existing.BaseVersion + && operation.ProposedVersion > existing.ProposedVersion; + } + private static string Describe(ItemKind kind) { return kind.ToString().ToLowerInvariant(); diff --git a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs index 8d465331d..db1eadc70 100644 --- a/src/FluentCMS.Core/Checks/ChangeSetChecks.cs +++ b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs @@ -71,6 +71,9 @@ public async Task> SubmitAsync(Guid changeSetId, Can 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. + await using var engine = inline ? scopes.CreateEngineScope(SystemInitiator.Checks) : (AsyncServiceScope?)null; + Job job; ChangeSet submitted; await using (var work = await unitOfWork.BeginAsync(cancellationToken)) @@ -110,13 +113,13 @@ public async Task> SubmitAsync(Guid changeSetId, Can ChangeSetCheckRun? run = null; try { - await using var engine = scopes.CreateEngineScope(SystemInitiator.Checks); - var ran = await engine.ServiceProvider.GetRequiredService() + var ran = await engine!.Value.ServiceProvider.GetRequiredService() .RunAsync(changeSetId, job.Id, CheckRunMode.Inline, null, CancellationToken.None); run = ran.IsSuccess ? ran.Value : null; } catch (Exception) { + // Check types never throw, so this is a fault such as a lost connection; the job records it if it happens again. } var after = await changeSets.GetAsync(changeSetId, CancellationToken.None); @@ -208,7 +211,9 @@ public async Task> SubmitAsync(Guid changeSetId, Can CheckStatus.Fail, 0, [], - [$"The checks could not run on this change set. Reopen it and submit it again; if they still cannot run, ask a person to look at job {jobId}."]), + [set.RevertsChangeSetId is null + ? $"The checks could not run on this change set. Reopen it and submit it again; if they still cannot run, ask a person to look at job {jobId}." + : $"The checks could not run on this revert, so its reviewer decides without them; a person can look at job {jobId}."]), cancellationToken); } @@ -338,7 +343,7 @@ private async Task AnyJudgedAsync(CancellationToken cancellationToken) var before = proposed is { IsSuccess: true } && operation.Kind == OperationKind.Update && operation.BaseVersion is { } baseVersion ? await entries.GetVersionAsync(operation.ItemId, baseVersion, cancellationToken) : null; - if (Problem(operation, entry.Errors, proposed?.Errors, before?.Errors) is { } unreadable) + if (Problem(set, operation, entry.Errors, proposed?.Errors, before?.Errors) is { } unreadable) { problems.Add(unreadable); continue; @@ -348,7 +353,7 @@ private async Task AnyJudgedAsync(CancellationToken cancellationToken) if (!types.TryGetValue(key, out var type)) { var read = await contentTypes.GetVersionAsync(key.ContentType, key.ContentTypeVersion, cancellationToken); - if (Problem(operation, read.Errors) is { } missing) + if (Problem(set, operation, read.Errors) is { } missing) { problems.Add(missing); continue; @@ -364,13 +369,15 @@ private async Task AnyJudgedAsync(CancellationToken cancellationToken) return (subjects, problems); } - private static string? Problem(ChangeOperation operation, params IReadOnlyList?[] errors) + private static string? Problem(ChangeSet set, ChangeOperation operation, params IReadOnlyList?[] errors) { var error = errors.FirstOrDefault(list => list is { Count: > 0 })?[0]; return error is null ? null : $"The {operation.ItemKind.ToString().ToLowerInvariant()} {operation.ItemId} could not be checked: {error.Message} " - + "A person can restore it; then reopen the change set and submit it again."; + + (set.RevertsChangeSetId is null + ? "A person can restore it; then reopen the change set and submit it again." + : "A person can restore it before the revert is approved."); } private static List Changed(JsonElement proposed, JsonElement before) diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 070269234..a3315760c 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -303,6 +303,8 @@ private async Task> AddAsync( { var set = stored.ChangeSet; + // An operation that moves the item's operation on to a newer version takes its place rather than joining the set. + var position = set.Operations.ToList().FindIndex(existing => existing.ItemKind == operation.ItemKind && existing.ItemId == operation.ItemId); var added = set.AddOperation(operation); if (!added.IsSuccess) { @@ -335,7 +337,14 @@ private async Task> AddAsync( return Conflict(set.Id); } - await InsertOperationAsync(set, set.Operations.Count - 1, previousVersion.Value, cancellationToken); + if (position >= 0) + { + await UpdateOperationAsync(set, position, previousVersion.Value, cancellationToken); + } + else + { + await InsertOperationAsync(set, set.Operations.Count - 1, previousVersion.Value, cancellationToken); + } return await CommitAsync(scope, set, Conflict(set.Id), cancellationToken); } @@ -445,6 +454,25 @@ await auditLog.AppendAsync( cancellationToken); } + private async Task UpdateOperationAsync(ChangeSet set, int position, Guid? previousVersionId, CancellationToken cancellationToken) + { + var operation = set.Operations[position]; + await context.ChangeSetOperations + .Where(row => row.ChangeSetId == set.Id && row.Position == position) + .ExecuteUpdateAsync(row => row.SetProperty(stored => stored.ProposedVersion, operation.ProposedVersion), cancellationToken); + + await auditLog.AppendAsync( + new NewAuditEvent("change_set.operation_updated") + { + SubjectType = ItemSubjectType(operation.ItemKind), + SubjectId = operation.ItemId, + ChangeSetId = set.Id, + Sources = set.Sources, + PreviousVersionId = previousVersionId, + }, + cancellationToken); + } + // Writes only over the revision this call read, so a concurrent writer that committed first leaves nothing to update. private async Task UpdateAsync(int revision, ChangeSet set, CancellationToken cancellationToken) { diff --git a/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs index 34ec16780..cba4bae72 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs @@ -22,18 +22,19 @@ public sealed class ChangeSetChecksTests // The worker polls once as the host starts, so a job enqueued later waits for the test. private static readonly Dictionary IdleQueue = new() { ["JobQueue:PollingInterval"] = "01:00:00" }; - // S2: a set with "best in class", an image without alt text and copy that cites no source fails three checks that say what to change; - // the fixed content passes. The checks, not the agent, move the set. + // S2: a set with "best in class", an image without alt text and copy that cites no source fails three checks that say what to change. + // The agent reopens it, updates the entry from the version the set proposes, and the fixed set passes. The checks, not the agent, + // move the set. [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] - public async Task A_small_set_is_checked_when_it_is_submitted_and_the_check_engine_moves_it(string provider) + public async Task A_small_set_is_checked_when_it_is_submitted_fixed_in_place_and_the_check_engine_moves_it(string provider) { await using var agent = await ConnectedAgent.StartAsync(provider); await ProductChecksAsync(agent); - var failing = await SubmitAsync(agent, "s2", "The best in class kettle ![](/media/kettle.jpg)"); - var passing = await SubmitAsync(agent, "s2-fixed", "A kettle that boils in two minutes ![A steel kettle](/media/kettle.jpg)", "exports/catalog.csv"); + var created = await CreateAsync(agent, "s2", "The best in class kettle ![](/media/kettle.jpg)"); + var failing = await agent.CallAsync("submit_change_set", new() { ["id"] = (string?)created["changeSet"]!["id"] }); Assert.Equal("checks_failed", (string?)failing["changeSet"]!["state"]); var checks = failing["checks"]!; @@ -44,21 +45,38 @@ public async Task A_small_set_is_checked_when_it_is_submitted_and_the_check_engi Assert.Equal("/title", (string?)results[1]!["findings"]![0]!["pointer"]); Assert.Null(failing["job"]); - Assert.Equal("awaiting_review", (string?)passing["changeSet"]!["state"]); - Assert.Equal("pass", (string?)passing["checks"]!["status"]); - // The run is stored with the set, and the backup job only waits in case it had not finished. var setId = (string)failing["changeSet"]!["id"]!; var read = await agent.CallAsync("get_change_set_checks", new() { ["id"] = setId }); Assert.Equal(1, (int)read["checks"]!["number"]!); Assert.Equal("queued", (string?)read["job"]!["state"]); + await agent.CallAsync("reopen_change_set", new() { ["id"] = setId }); + var fixedEntry = await agent.CallAsync("update_entry", new() + { + ["id"] = (string?)created["entry"]!["id"], + ["base_version"] = 1, + ["fields"] = new JsonObject { ["title"] = "A kettle that boils in two minutes ![A steel kettle](/media/kettle.jpg)" }, + ["session_id"] = "s2", + ["rationale"] = "Fix what the checks found.", + ["sources"] = new JsonArray("exports/catalog.csv"), + }); + var passing = await agent.CallAsync("submit_change_set", new() { ["id"] = setId }); + + Assert.Equal(setId, (string?)fixedEntry["changeSet"]!["id"]); + Assert.Equal(("awaiting_review", "pass", 2), ((string?)passing["changeSet"]!["state"], (string?)passing["checks"]!["status"], (int)passing["checks"]!["number"]!)); + Assert.Equal(2, (int)passing["checks"]!["items"]![0]!["version"]!); + var stored = await agent.AsPersonAsync(directory => directory.GetAsync(Guid.Parse(setId))); + var operation = Assert.Single(stored.Operations); + Assert.Equal((OperationKind.Create, (int?)null, (int?)2), (operation.Kind, operation.BaseVersion, operation.ProposedVersion)); + var events = await agent.QueryAsync(context => context.AuditEvents.AsNoTracking() .Where(row => row.ChangeSetId == Guid.Parse(setId) || row.SubjectId == Guid.Parse(setId)) .ToListAsync()); - Assert.Equal("agent", events.Single(row => row.Operation == "change_set.submitted").InitiatorKind); - var byEngine = events.Where(row => row.Operation is "change_set.checked" or "change_set.checks_failed").ToList(); - Assert.Equal(2, byEngine.Count); + Assert.All(events.Where(row => row.Operation == "change_set.submitted"), row => Assert.Equal("agent", row.InitiatorKind)); + Assert.Single(events, row => row.Operation == "change_set.operation_updated"); + var byEngine = events.Where(row => row.Operation is "change_set.checked" or "change_set.checks_failed" or "change_set.awaiting_review").ToList(); + Assert.Equal(4, byEngine.Count); Assert.All(byEngine, row => Assert.Equal(("system", "checks", (Guid?)null), (row.InitiatorKind, row.InitiatorName, row.AgentCredentialId))); } @@ -171,6 +189,15 @@ public async Task A_set_whose_check_job_ended_without_moving_it_is_failed_when_i Assert.Contains(jobId.ToString(), (string?)Assert.Single(again["checks"]!["problems"]!.AsArray())); var reopened = await agent.CallAsync("reopen_change_set", new() { ["id"] = setId }); Assert.Equal("open", (string?)reopened["changeSet"]!["state"]); + + // A person can fail a stuck set too. + var other = await SubmitAsync(agent, "stuck-too", "A kettle", "exports/catalog.csv"); + var otherId = Guid.Parse((string)other["changeSet"]!["id"]!); + var otherJob = Guid.Parse((string)other["job"]!["id"]!); + await agent.QueryAsync(context => context.Jobs.Where(row => row.Id == otherJob).ExecuteUpdateAsync(row => row.SetProperty(job => job.State, JobState.Failed))); + var byPerson = await agent.AsPersonAsync(checks => checks.SubmitAsync(otherId)); + Assert.Equal(ChangeSetState.ChecksFailed, byPerson.ChangeSet.State); + Assert.Contains(otherJob.ToString(), Assert.Single(byPerson.Run!.Problems)); } // A set with nothing to check passes and waits for review; routing decides more once it lands (F-RTE). diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs index 4e3c49764..76b4ac6c6 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -94,6 +94,42 @@ public void An_item_appears_at_most_once_in_a_set() Assert.Single(changeSet.Operations); } + // A set reopened to fix an item takes the item's newer draft: the same change from the same published version, proposing the + // later draft, so the item still has one diff and one version to restore (CHG-03, CHG-06). + [Theory] + [InlineData(OperationKind.Create, null, 1)] + [InlineData(OperationKind.Update, 3, 4)] + public void An_update_from_the_version_a_set_proposes_moves_the_item_s_operation_on(OperationKind kind, int? baseVersion, int proposed) + { + var changeSet = TestChangeSets.Open(); + var item = Guid.NewGuid(); + changeSet.AddOperation(ChangeOperation.Create(kind, ItemKind.Entry, item, baseVersion, proposed).Value); + + var result = changeSet.AddOperation(ChangeOperation.Create(kind, ItemKind.Entry, item, baseVersion, proposed + 1).Value); + + Assert.True(result.IsSuccess); + var operation = Assert.Single(changeSet.Operations); + Assert.Equal((kind, baseVersion, (int?)proposed + 1), (operation.Kind, operation.BaseVersion, operation.ProposedVersion)); + } + + [Fact] + public void An_operation_that_proposes_no_later_draft_or_that_changes_a_content_type_is_still_a_duplicate() + { + var changeSet = TestChangeSets.Open(); + var entry = Guid.NewGuid(); + var type = Guid.NewGuid(); + changeSet.AddOperation(TestChangeSets.Update(ItemKind.Entry, entry)); + changeSet.AddOperation(TestChangeSets.Update(ItemKind.Schema, type)); + + var stale = changeSet.AddOperation(ChangeOperation.Create(OperationKind.Update, ItemKind.Entry, entry, 1, 2).Value); + var schema = changeSet.AddOperation(ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, type, 2, 3).Value); + + Assert.Equal(ChangeSetErrorCodes.ItemDuplicate, Assert.Single(stale.Errors).Code); + Assert.Contains("update it from version 2", stale.Errors[0].Message); + Assert.Equal(ChangeSetErrorCodes.ItemDuplicate, Assert.Single(schema.Errors).Code); + Assert.Equal([2, 2], changeSet.Operations.Select(operation => operation.ProposedVersion!.Value)); + } + [Fact] public void A_set_with_no_operations_cannot_leave_open() { From 0e0ce248fd62fa51c4147f29c2d246670a992a3b Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 02:10:01 -0400 Subject: [PATCH 4/5] Merge origin/v2 and regenerate the CheckRuns migrations --- CONTRIBUTING.md | 2 +- ...s => 20261019055336_CheckRuns.Designer.cs} | 122 +++++++++++++++++- ...eckRuns.cs => 20261019055336_CheckRuns.cs} | 0 ...s => 20261019055319_CheckRuns.Designer.cs} | 122 +++++++++++++++++- ...eckRuns.cs => 20261019055319_CheckRuns.cs} | 0 5 files changed, 243 insertions(+), 3 deletions(-) rename src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/{20261019043617_CheckRuns.Designer.cs => 20261019055336_CheckRuns.Designer.cs} (91%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/{20261019043617_CheckRuns.cs => 20261019055336_CheckRuns.cs} (100%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/{20261019043548_CheckRuns.Designer.cs => 20261019055319_CheckRuns.Designer.cs} (91%) rename src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/{20261019043548_CheckRuns.cs => 20261019055319_CheckRuns.cs} (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3ab6e090..bf7efcbfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,7 +138,7 @@ An LLM-judged type asks a model only through `IModelGateway`, which it reads fro A check is a versioned definition under a slug: a check type and its settings, a description of what it enforces, a severity (block or warn), an optional scope of content types and fields, and test examples, at least one it must pass and one it must fail. `ICheckDirectory` activates a version only after running its examples, and names each example that does not get its verdict (CHK-02). A revision leaves the active version in place until the new one is activated, and only a person activates or deactivates a check. `ICheckRunner` runs the active checks that apply to a piece of content and gives each one's status: pass, warn for a failing check of warn severity, fail, or needs review. Agents run it on a draft with the `check_draft` tool before they propose it (CHK-04). -A submitted change set is checked as the check engine (`SystemInitiator.Checks`, ADR-0012), never as the agent: submit it through `IChangeSetChecks.SubmitAsync`, not by moving it to checks running yourself. While every active check is deterministic, a set of up to 50 operations is checked on the request that submits it; any other set is checked by a `checks.run` job, which reports its progress (PERF-02). Every entry and entity the set creates or updates is checked at the version it proposes, and an LLM-judged check charges its model call to the agent that opened the set (SEC-04). An item the engine cannot read, such as an entry deleted since, is a problem that fails the run. So is a job whose last attempt throws, and a set whose job ended without moving it is failed when it is submitted again, so it can be reopened. Each run names its submission's job, and a run of an earlier submission changes nothing. A revert is never held by its checks: it goes to review with their results (CHG-06). The run, each item's results with their findings and model calls, and the move it decides (checks failed when a check fails, otherwise awaiting review) commit together, numbered per set, with the audit events `change_set.checked` and `check.judged` (CHK-03, CHK-05). Agents read the run of the latest submission and its job with the `get_change_set_checks` tool, which shows a job's state and progress but not the error a worker recorded. +A submitted change set is checked as the check engine (`SystemInitiator.Checks`, ADR-0012), never as the agent: submit it through `IChangeSetChecks.SubmitAsync`, not by moving it to checks running yourself. While every active check is deterministic, a set of up to 50 operations is checked on the request that submits it; any other set is checked by a `checks.run` job, which reports its progress (PERF-02). Every entry and entity the set creates or updates is checked at the version it proposes, and an LLM-judged check charges its model call to the agent that opened the set (SEC-04). An item the engine cannot read, such as an entry deleted since, is a problem that fails the run. So is a job whose last attempt throws, and a set whose job ended without moving it is failed when it is submitted again, so it can be reopened. A reopened set is fixed in place: a set holds one operation per item, and a later draft of an entry or entity it already proposes, the same change from the same published version, moves that operation on to the new draft and appends `change_set.operation_updated`. Each run names its submission's job, and a run of an earlier submission changes nothing. A revert is never held by its checks: it goes to review with their results (CHG-06). The run, each item's results with their findings and model calls, and the move it decides (checks failed when a check fails, otherwise awaiting review) commit together, numbered per set, with the audit events `change_set.checked` and `check.judged` (CHK-03, CHK-05). Agents read the run of the latest submission and its job with the `get_change_set_checks` tool, which shows a job's state and progress but not the error a worker recorded. ## Code style diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055336_CheckRuns.Designer.cs similarity index 91% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.Designer.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055336_CheckRuns.Designer.cs index dce027797..88e164932 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.Designer.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055336_CheckRuns.Designer.cs @@ -13,7 +13,7 @@ namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql { [DbContext(typeof(PostgreSqlFluentCmsDbContext))] - [Migration("20261019043617_CheckRuns")] + [Migration("20261019055336_CheckRuns")] partial class CheckRuns { /// @@ -781,6 +781,108 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => { b.Property("Id") @@ -1267,6 +1369,24 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => { b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055336_CheckRuns.cs similarity index 100% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019043617_CheckRuns.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019055336_CheckRuns.cs diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055319_CheckRuns.Designer.cs similarity index 91% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.Designer.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055319_CheckRuns.Designer.cs index 0958007a4..c46bedcef 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.Designer.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055319_CheckRuns.Designer.cs @@ -11,7 +11,7 @@ namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite { [DbContext(typeof(SqliteFluentCmsDbContext))] - [Migration("20261019043548_CheckRuns")] + [Migration("20261019055319_CheckRuns")] partial class CheckRuns { /// @@ -775,6 +775,108 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => { b.Property("Id") @@ -1257,6 +1359,24 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => { b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055319_CheckRuns.cs similarity index 100% rename from src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019043548_CheckRuns.cs rename to src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019055319_CheckRuns.cs From 1d303b8078178e31aa38a9048102cd89bfb86f53 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 02:42:10 -0400 Subject: [PATCH 5/5] Say when an item was published by another set since --- src/FluentCMS.Core/ChangeSets/ChangeSet.cs | 20 ++++++++++++++----- .../ChangeSets/ChangeSetTests.cs | 15 ++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index 881953041..bf42d384d 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -192,11 +192,7 @@ public Result AddOperation(ChangeOperation operation) var existing = _operations[position]; if (!Supersedes(existing, operation)) { - return Result.Failure( - ChangeSetErrorCodes.ItemDuplicate, - existing.ProposedVersion is { } proposed && operation.ItemKind is ItemKind.Entry or ItemKind.Entity - ? $"The {Describe(operation.ItemKind)} {operation.ItemId} already has an operation in this change set; update it from version {proposed}, which the set proposes, to change what it proposes." - : $"The {Describe(operation.ItemKind)} {operation.ItemId} already has an operation in this change set."); + return Result.Failure(ChangeSetErrorCodes.ItemDuplicate, Duplicate(existing, operation)); } _operations[position] = ChangeOperation.Restore(existing.Kind, existing.ItemKind, existing.ItemId, existing.BaseVersion, operation.ProposedVersion); @@ -297,6 +293,20 @@ private static string Describe(ChangeSetState state) }; } + // An item published by another set since this one proposed it changes kind or base, and this set can no longer change it. + private static string Duplicate(ChangeOperation existing, ChangeOperation operation) + { + var item = $"The {Describe(operation.ItemKind)} {operation.ItemId}"; + if (operation.ItemKind is not (ItemKind.Entry or ItemKind.Entity) || existing.ProposedVersion is not { } proposed) + { + return $"{item} already has an operation in this change set."; + } + + return operation.Kind != existing.Kind || operation.BaseVersion != existing.BaseVersion + ? $"{item} was published by another change set after this one proposed it, so this set can no longer change it; propose the change in a new change set." + : $"{item} already has an operation in this change set; update it from version {proposed}, which the set proposes, to change what it proposes."; + } + // The same change from the same published version, proposing a later draft. private static bool Supersedes(ChangeOperation existing, ChangeOperation operation) { diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs index 76b4ac6c6..3eb3c5e47 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -130,6 +130,21 @@ public void An_operation_that_proposes_no_later_draft_or_that_changes_a_content_ Assert.Equal([2, 2], changeSet.Operations.Select(operation => operation.ProposedVersion!.Value)); } + // An item another set published after this one proposed it comes back as an update from that published version, which this set + // cannot take, so the agent is told to propose the change anew rather than to update from a version it cannot. + [Fact] + public void An_item_published_by_another_set_since_says_so() + { + var changeSet = TestChangeSets.Open(); + var entry = Guid.NewGuid(); + changeSet.AddOperation(ChangeOperation.Create(OperationKind.Create, ItemKind.Entry, entry, null, 1).Value); + + var result = changeSet.AddOperation(ChangeOperation.Create(OperationKind.Update, ItemKind.Entry, entry, 2, 3).Value); + + Assert.Equal(ChangeSetErrorCodes.ItemDuplicate, Assert.Single(result.Errors).Code); + Assert.Contains("was published by another change set", result.Errors[0].Message); + } + [Fact] public void A_set_with_no_operations_cannot_leave_open() {