diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c3d52d7c..4cd25fe6a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,6 +138,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. 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. + Search (`ISearchIndex` in `FluentCMS.Core.Search`, SRC-01, ADR-0010) indexes each entry's latest version in the same unit of work as the write, as the lowercase words of every string and number in its fields (`SearchText`), so a result appears the moment its write commits. PostgreSQL keeps them in a `tsvector` with a GIN index and SQLite in an FTS5 table, both outside the EF model and created by their migrations; an external engine implements the same interface, registered with `AddFluentCmsExtension(ServiceLifetime.Scoped)` when it needs the request's scope. `ISearch` combines the matches with the entry rows, so deleting, restoring and publishing change results without touching the index, follows a match to the entries that reference it, and returns only types the caller may read. It reads the matches a page at a time, `Search__PageSize` (1,000 by default), and drops what the scope, deletions and filters leave out, until enough are left, the index has no more, or it has read `Search__MaxMatches` (10,000). A result that stopped there says it is not complete. Each hit has the state the entry tools show, in review included. A string that is a GUID, such as a reference, is not indexed. ## Code style 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/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 0bb5dd987..c2128e21a 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,20 +48,23 @@ 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 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.")), readOnly: false, - (call, cancellationToken) => MoveAsync(call, ChangeSetState.ChecksRunning, cancellationToken)), + SubmitAsync), 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, - (call, cancellationToken) => MoveAsync(call, ChangeSetState.Open, cancellationToken)), + ReopenAsync), ]; private static async Task OpenAsync(ToolInvocation call, CancellationToken cancellationToken) @@ -81,7 +85,7 @@ private static async Task OpenAsync(ToolInvocation call, Cancellatio }); } - private static async Task MoveAsync(ToolInvocation call, ChangeSetState target, CancellationToken cancellationToken) + private static async Task SubmitAsync(ToolInvocation call, CancellationToken cancellationToken) { var arguments = new ToolArgumentsReader(call.Arguments); var id = arguments.RequiredGuid("id"); @@ -90,15 +94,46 @@ private static async Task MoveAsync(ToolInvocation call, ChangeSetSt return ToolResult.Failure(errors); } - var set = await call.Services.GetRequiredService().TransitionAsync(id!.Value, target, cancellationToken); + 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 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.", + }, + }); + } + + private static async Task ReopenAsync(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 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: 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/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/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 de01fe9cd..cb1d883f2 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/Builtin/Tools/ToolJson.cs b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs index 795b5e97a..85806c136 100644 --- a/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs +++ b/src/FluentCMS.Core/Builtin/Tools/ToolJson.cs @@ -1,9 +1,11 @@ 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.Jobs; using FluentCMS.Core.Schema; namespace FluentCMS.Core.Builtin.Tools; @@ -28,6 +30,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))]), + }; + } + + // 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 + { + ["id"] = job.Id.ToString(), + ["state"] = Name(job.State), + ["done"] = job.Progress.Done, + ["total"] = job.Progress.Total, + ["message"] = job.Progress.Message, + }; + } + public static JsonObject ContentType(ContentType type) { var fields = new JsonArray(); diff --git a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs index ad64d8332..bf42d384d 100644 --- a/src/FluentCMS.Core/ChangeSets/ChangeSet.cs +++ b/src/FluentCMS.Core/ChangeSets/ChangeSet.cs @@ -184,12 +184,19 @@ 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, Duplicate(existing, operation)); + } + + _operations[position] = ChangeOperation.Restore(existing.Kind, existing.ItemKind, existing.ItemId, existing.BaseVersion, operation.ProposedVersion); + return Result.Success(); } _operations.Add(operation); @@ -286,6 +293,30 @@ 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) + { + 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/ChangeSetCheckRun.cs b/src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs new file mode 100644 index 000000000..08ff4deab --- /dev/null +++ b/src/FluentCMS.Core/Checks/ChangeSetCheckRun.cs @@ -0,0 +1,72 @@ +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, 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, + 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 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. +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 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 + // 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..db1eadc70 --- /dev/null +++ b/src/FluentCMS.Core/Checks/ChangeSetChecks.cs @@ -0,0 +1,392 @@ +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, + 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 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"; + + // 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); + } + + // 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); + + // 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)) + { + 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, 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 + { + 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); + return Result.Success(new ChangeSetSubmission(after.IsSuccess ? after.Value : submitted, run, 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, + jobId, + 0, + mode, + started, + clock.GetUtcNow(), + status, + results.Sum(result => result.Model?.Cost ?? 0), + items, + problems); + + 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 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, + now, + CheckStatus.Fail, + 0, + [], + [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); + } + + 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?.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. + 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(ChangeSet set, ChangeSetCheckRun run, 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. 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 == run.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, 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); + } + + 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); + } + + 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)) + { + // The version a set proposes is most often the entry's latest, which the entry read already holds. + var entry = await entries.GetAsync(operation.ItemId, cancellationToken); + var proposed = !entry.IsSuccess + ? null + : entry.Value.Latest.Version == operation.ProposedVersion + ? Result.Success(entry.Value.Latest) + : await entries.GetVersionAsync(operation.ItemId, operation.ProposedVersion!.Value, 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(set, 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(set, 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(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} " + + (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) + { + 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 51de78c58..df0475845 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -38,6 +38,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(); + // Reads through IChangeSetLists and IEntityFacts, which Infrastructure provides. services.AddScoped(); 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 f3d39ee5c..e36d2581b 100644 --- a/src/FluentCMS.Host/FluentCmsHost.cs +++ b/src/FluentCMS.Host/FluentCmsHost.cs @@ -173,7 +173,8 @@ 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, 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) { var registered = services.Last(descriptor => descriptor.ServiceType == typeof(ICurrentInitiator)); @@ -184,6 +185,7 @@ private static void RunJobsAsTheirInitiator(IServiceCollection services) 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.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 2e5779b2d..6e7471dc1 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -316,6 +316,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) { @@ -348,7 +350,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); } @@ -458,6 +467,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/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..f3cbeacbe --- /dev/null +++ b/src/FluentCMS.Infrastructure/Checks/CheckRunRow.cs @@ -0,0 +1,77 @@ +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; } + + // 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; } + + 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..ab6f5b78c --- /dev/null +++ b/src/FluentCMS.Infrastructure/Checks/CheckRuns.cs @@ -0,0 +1,119 @@ +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, + JobId = numbered.JobId, + 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.JobId, 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 c58de4487..4d6737a75 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -152,6 +152,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 145b7e19c..0cfbbc350 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -54,6 +54,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); @@ -76,6 +80,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/20261019070938_CheckRuns.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019070938_CheckRuns.Designer.cs new file mode 100644 index 000000000..f8aa83291 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019070938_CheckRuns.Designer.cs @@ -0,0 +1,1469 @@ +// +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("20261019070938_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("JobId") + .HasColumnType("uuid"); + + 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.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") + .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.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) + .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/20261019070938_CheckRuns.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019070938_CheckRuns.cs new file mode 100644 index 000000000..d57d16a4c --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019070938_CheckRuns.cs @@ -0,0 +1,101 @@ +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), + 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), + 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 cacc463ac..fa298c4fd 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,55 @@ 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("JobId") + .HasColumnType("uuid"); + + 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") @@ -1146,6 +1288,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) @@ -1155,6 +1306,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/20261019070916_CheckRuns.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019070916_CheckRuns.Designer.cs new file mode 100644 index 000000000..4332280fe --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019070916_CheckRuns.Designer.cs @@ -0,0 +1,1459 @@ +// +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("20261019070916_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("JobId") + .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.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") + .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.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) + .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/20261019070916_CheckRuns.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019070916_CheckRuns.cs new file mode 100644 index 000000000..4679a7bff --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019070916_CheckRuns.cs @@ -0,0 +1,101 @@ +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), + 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), + 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 2b4cc51e2..455bfb118 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,55 @@ 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("JobId") + .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") @@ -1136,6 +1278,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) @@ -1145,6 +1296,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..cba4bae72 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Mcp/ChangeSetChecksTests.cs @@ -0,0 +1,408 @@ +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; +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" }; + + // 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 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_fixed_in_place_and_the_check_engine_moves_it(string provider) + { + await using var agent = await ConnectedAgent.StartAsync(provider); + await ProductChecksAsync(agent); + + 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"]!; + 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"]); + + // 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.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))); + } + + // 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, even when a person, who may move any set, runs it. + [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.AsPersonAsync(checks => checks.RunAsync(setId, earlierJob, CheckRunMode.Job)); + var again = await agent.CallAsync("submit_change_set", new() { ["id"] = setId.ToString() }); + 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!.Number); + Assert.Equal(2, (int)again["checks"]!["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"]!)); + 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"]); + } + + // 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 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). + [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). 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( + provider, + 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); + + 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. + [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 }); + } + + // 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); + 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 "!!!", 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; + + 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("!!!")); + + 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 0b65e632e..bc21c6b71 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs @@ -3,6 +3,7 @@ using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Persistence; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; @@ -26,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", @@ -61,6 +67,12 @@ public async Task AsPersonAsync(Func>(administrator, action)).Value; } + public Task AsPersonAsync(Func> action) + where TService : notnull + { + return host.AsUserAsync(administrator, action); + } + // Runs as the agent, as a call over MCP would, for work a test drives without a tool. public async Task AsAgentAsync(Func> action) { diff --git a/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs index bfc1ff666..27ef1147a 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/DeskToolsTests.cs @@ -16,9 +16,8 @@ public async Task An_agent_reads_the_desk_queues_and_counts_of_its_own_sets(stri await using var agent = await ConnectedAgent.StartAsync(provider); var first = await SubmitAsync(agent, "product"); var second = await SubmitAsync(agent, "category"); - var failed = await SubmitAsync(agent, "brand"); - await MoveAsync(agent, first, ChangeSetState.AwaitingReview); - await MoveAsync(agent, second, ChangeSetState.AwaitingReview); + var failed = await SubmitAsync(agent, "brand", submit: false); + await MoveAsync(agent, failed, ChangeSetState.ChecksRunning); await MoveAsync(agent, failed, ChangeSetState.ChecksFailed); var someoneElses = await PersonsSetAsync(agent, "page"); var events = await agent.QueryAsync(context => Task.FromResult(context.AuditEvents.Count())); @@ -53,7 +52,6 @@ public async Task An_agent_reads_who_rejected_its_set_and_why_and_not_the_review { await using var agent = await ConnectedAgent.StartAsync(provider); var set = await SubmitAsync(agent, "product"); - await MoveAsync(agent, set, ChangeSetState.AwaitingReview); await agent.AsPersonAsync(directory => directory.RejectAsync(set, "The type duplicates item.")); var someoneElses = await PersonsSetAsync(agent, "page"); @@ -104,8 +102,9 @@ private static string Message(JsonObject failure) return [.. list["changeSets"]!.AsArray().Select(set => (string?)set!["id"])]; } - // A set the agent opens and submits through its tools, proposing one new content type. - private static async Task SubmitAsync(ConnectedAgent agent, string slug) + // A set the agent opens and submits through its tools, proposing one new content type. No check reads a content type, so the + // set passes its checks and waits for review; one left unsubmitted is moved on by a person, standing in for the engine. + private static async Task SubmitAsync(ConnectedAgent agent, string slug, bool submit = true) { var opened = await agent.CallAsync("open_change_set", new() { ["rationale"] = $"Add the {slug} type." }); var id = (string)opened["changeSet"]!["id"]!; @@ -116,7 +115,11 @@ private static async Task SubmitAsync(ConnectedAgent agent, string slug) ["definition"] = new JsonObject { ["fields"] = new JsonArray(new JsonObject { ["name"] = "title", ["type"] = "text" }) }, ["change_set_id"] = id, }); - await agent.CallAsync("submit_change_set", new() { ["id"] = id }); + if (submit) + { + await agent.CallAsync("submit_change_set", new() { ["id"] = id }); + } + return Guid.Parse(id); } diff --git a/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs b/tests/FluentCMS.IntegrationTests/Mcp/McpAgents.cs index 01eafd809..c09c1db96 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_change_set_review", "get_content_type", "get_desk_counts", "get_entity_facts", "get_entity_relations", "get_entry", - "get_entry_history", "get_resolved_entry", "get_schema_document", "get_where_used", "list_content_types", "list_desk_queue", - "list_entries", "list_stale_facts", "list_unsourced_facts", "open_change_set", "relate_entities", "reopen_change_set", - "revise_content_type", "search_entries", "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_change_set_review", "get_content_type", "get_desk_counts", "get_entity_facts", "get_entity_relations", + "get_entry", "get_entry_history", "get_resolved_entry", "get_schema_document", "get_where_used", "list_content_types", + "list_desk_queue", "list_entries", "list_stale_facts", "list_unsourced_facts", "open_change_set", "relate_entities", + "reopen_change_set", "revise_content_type", "search_entries", "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..ccc051145 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.AddEngineScopes(); + } services.AddFluentCmsCore(); services.AddFluentCmsInfrastructure(configuration); diff --git a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs index 4e3c49764..3eb3c5e47 100644 --- a/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs +++ b/tests/FluentCMS.Tests/ChangeSets/ChangeSetTests.cs @@ -94,6 +94,57 @@ 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)); + } + + // 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() { 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 acf47378e..0d3efdb9d 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"]), new("DSK", "Read the review desk's queues and counts, read-only", ["get_desk_counts", "list_desk_queue"]), new("DSK", "Read a change set's review status and decisions, read-only", ["get_change_set_review"]), Missing("MED", "Upload and describe media", "#2427"),