Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 323 additions & 0 deletions src/FluentCMS.Core/Builtin/Tools/EntityTools.cs

Large diffs are not rendered by default.

98 changes: 9 additions & 89 deletions src/FluentCMS.Core/Builtin/Tools/EntryTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
using System.Text.Json.Nodes;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Content;
using FluentCMS.Core.Persistence;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;
using FluentCMS.Core.Tools;
using Microsoft.Extensions.DependencyInjection;

Expand All @@ -14,9 +12,6 @@ namespace FluentCMS.Core.Builtin.Tools;
// which go into a change set a person reviews. Entities are entries of entity types, so their fields are facts.
public sealed class EntryTools : IToolProvider
{
private const string FindEntries = "Call list_entries with the content type's slug for the ids of its entries.";
private const string ReadSchema = "Call get_schema_document with the content type's slug for its fields and the values each one takes.";

public IReadOnlyList<AgentTool> Tools { get; } =
[
new AgentTool(
Expand Down Expand Up @@ -124,7 +119,7 @@ private static async Task<ToolResult> ListAsync(ToolInvocation call, Cancellatio
}

var entries = await call.Services.GetRequiredService<IEntryDirectory>().ListAsync(contentType!, cancellationToken);
return ToolResult.From(entries, list => new JsonObject { ["entries"] = new JsonArray([.. list.Select(ToolJson.EntrySummary)]) }, Hint(entries.Errors));
return ToolResult.From(entries, list => new JsonObject { ["entries"] = new JsonArray([.. list.Select(ToolJson.EntrySummary)]) }, ProposedEntryWrite.Hint(entries.Errors));
}

private static async Task<ToolResult> GetAsync(ToolInvocation call, CancellationToken cancellationToken)
Expand All @@ -141,7 +136,7 @@ private static async Task<ToolResult> GetAsync(ToolInvocation call, Cancellation
var entry = await directory.GetAsync(id!.Value, cancellationToken);
if (!entry.IsSuccess || version is not { } number)
{
return ToolResult.From(entry, read => ToolJson.Entry(read), Hint(entry.Errors));
return ToolResult.From(entry, read => ToolJson.Entry(read), ProposedEntryWrite.Hint(entry.Errors));
}

var stored = await directory.GetVersionAsync(id.Value, number, cancellationToken);
Expand All @@ -158,7 +153,7 @@ private static async Task<ToolResult> HistoryAsync(ToolInvocation call, Cancella
}

var history = await call.Services.GetRequiredService<IEntryDirectory>().HistoryAsync(id!.Value, cancellationToken);
return ToolResult.From(history, versions => new JsonObject { ["versions"] = new JsonArray([.. versions.Select(ToolJson.EntryVersion)]) }, Hint(history.Errors));
return ToolResult.From(history, versions => new JsonObject { ["versions"] = new JsonArray([.. versions.Select(ToolJson.EntryVersion)]) }, ProposedEntryWrite.Hint(history.Errors));
}

private static async Task<ToolResult> ResolvedAsync(ToolInvocation call, CancellationToken cancellationToken)
Expand All @@ -172,7 +167,7 @@ private static async Task<ToolResult> ResolvedAsync(ToolInvocation call, Cancell
}

var resolved = await call.Services.GetRequiredService<IResolvedEntries>().ResolveAsync(id!.Value, version, cancellationToken);
return ToolResult.From(resolved, ToolJson.Resolved, Hint(resolved.Errors));
return ToolResult.From(resolved, ToolJson.Resolved, ProposedEntryWrite.Hint(resolved.Errors));
}

private static async Task<ToolResult> WhereUsedAsync(ToolInvocation call, CancellationToken cancellationToken)
Expand Down Expand Up @@ -204,7 +199,7 @@ private static async Task<ToolResult> WhereUsedAsync(ToolInvocation call, Cancel
: await whereUsed.ForEntityFieldAsync(id, field, cancellationToken)
: Result<IReadOnlyList<EntryUsage>>.Success(await whereUsed.ForMediaAsync(assetId!.Value, cancellationToken));

return ToolResult.From(usages, list => new JsonObject { ["usages"] = new JsonArray([.. list.Select(ToolJson.Usage)]) }, Hint(usages.Errors));
return ToolResult.From(usages, list => new JsonObject { ["usages"] = new JsonArray([.. list.Select(ToolJson.Usage)]) }, ProposedEntryWrite.Hint(usages.Errors));
}

private static async Task<ToolResult> CreateAsync(ToolInvocation call, CancellationToken cancellationToken)
Expand All @@ -220,15 +215,14 @@ private static async Task<ToolResult> CreateAsync(ToolInvocation call, Cancellat
return ToolResult.Failure(errors);
}

return await ProposeAsync(
return await ProposedEntryWrite.RunAsync(
call,
target!,
entries => entries.CreateAsync(new NewEntry(contentType!, fields!.Value), cancellationToken),
(entry, kind) => ChangeOperation.Create(OperationKind.Create, kind, entry.Id, null, entry.LatestVersion),
cancellationToken);
}

// The operation is based on what is published, which is what publishing it replaces; a draft never published is still a create.
private static async Task<ToolResult> UpdateAsync(ToolInvocation call, CancellationToken cancellationToken)
{
var arguments = new ToolArgumentsReader(call.Arguments);
Expand All @@ -243,13 +237,11 @@ private static async Task<ToolResult> UpdateAsync(ToolInvocation call, Cancellat
return ToolResult.Failure(errors);
}

return await ProposeAsync(
return await ProposedEntryWrite.RunAsync(
call,
target!,
entries => entries.UpdateAsync(id!.Value, baseVersion!.Value, fields!.Value, cancellationToken),
(entry, kind) => entry.PublishedVersion is { } published
? ChangeOperation.Create(OperationKind.Update, kind, entry.Id, published, entry.LatestVersion)
: ChangeOperation.Create(OperationKind.Create, kind, entry.Id, null, entry.LatestVersion),
ProposedEntryWrite.Update,
cancellationToken);
}

Expand All @@ -265,7 +257,7 @@ private static async Task<ToolResult> DeleteAsync(ToolInvocation call, Cancellat
return ToolResult.Failure(errors);
}

return await ProposeAsync(
return await ProposedEntryWrite.RunAsync(
call,
target!,
entries => entries.GetAsync(id!.Value, cancellationToken),
Expand All @@ -277,77 +269,5 @@ private static async Task<ToolResult> DeleteAsync(ToolInvocation call, Cancellat
cancellationToken);
}

// The version an agent writes and its operation in the change set commit together or not at all, as for content types, so no
// version an agent writes exists outside a change set a person reviews (AUD-N1, CHG-01).
private static async Task<ToolResult> ProposeAsync(
ToolInvocation call,
ChangeSetTarget target,
Func<IEntryDirectory, Task<Result<Entry>>> write,
Func<Entry, ItemKind, Result<ChangeOperation>> operation,
CancellationToken cancellationToken)
{
var services = call.Services;
var ready = await target.CheckAsync(services, cancellationToken);
if (!ready.IsSuccess)
{
return ToolResult.Failure(ready.Errors);
}

// Each directory finds its expected failures before its own unit of work, so leaving this one uncommitted undoes them all.
await using var scope = await services.GetRequiredService<IUnitOfWork>().BeginAsync(cancellationToken);

var entry = await write(services.GetRequiredService<IEntryDirectory>());
if (!entry.IsSuccess)
{
return ToolResult.Failure(entry.Errors, Hint(entry.Errors));
}

// Types are never deleted, but reading one is scope-checked, so an agent that may write a type's entries without reading it is refused here.
var type = await services.GetRequiredService<IContentTypeDirectory>().GetAsync(entry.Value.ContentType, cancellationToken);
if (!type.IsSuccess)
{
return ToolResult.Failure(type.Errors);
}

var kind = type.Value.Definition.IsEntity ? ItemKind.Entity : ItemKind.Entry;
var proposed = operation(entry.Value, kind);
if (!proposed.IsSuccess)
{
return ToolResult.Failure(proposed.Errors, Hint(proposed.Errors));
}

var set = await target.AddAsync(services.GetRequiredService<IChangeSetDirectory>(), proposed.Value, cancellationToken);
if (!set.IsSuccess)
{
return ToolResult.Failure(set.Errors);
}

await scope.CommitAsync(cancellationToken);

return ToolResult.Success(new JsonObject
{
["entry"] = ToolJson.Entry(entry.Value),
["changeSet"] = ToolJson.ChangeSet(set.Value),
["next"] = $"The {ToolJson.Name(proposed.Value.Kind)} is in change set {set.Value.Id}, which stays open for more "
+ "operations. Call submit_change_set when it is complete; checks run and a person reviews it before it is published.",
});
}

private const string FieldsForm = "an object with one property per field, such as {\"headline\": \"The Widget\"}";

private static string? Hint(IReadOnlyList<Error> errors)
{
return errors.FirstOrDefault()?.Code switch
{
ContentErrorCodes.VersionConflict => "Call get_entry for the latest version and its fields, then update from that version.",
ContentErrorCodes.EntryNotFound => FindEntries,
ContentErrorCodes.VersionNotFound => "Call get_entry_history for the versions the entry has.",
ContentErrorCodes.FieldsMalformed or ContentErrorCodes.FieldUnknown or ContentErrorCodes.FieldRequired or SchemaErrorCodes.ValueInvalid => ReadSchema,
ContentErrorCodes.ReferenceNotFound or ContentErrorCodes.ReferenceTypeMismatch =>
"Call list_entries with the referenced content type's slug for the ids a reference field can take.",
ContentErrorCodes.EntryNotPublished => "Only a published entry can be taken offline; get_entry shows its publishedVersion.",
SchemaErrorCodes.ContentTypeNotFound => "Call list_content_types for the slugs of the site's content types.",
_ => null,
};
}
}
100 changes: 100 additions & 0 deletions src/FluentCMS.Core/Builtin/Tools/ProposedEntryWrite.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using System.Text.Json.Nodes;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Content;
using FluentCMS.Core.Entities;
using FluentCMS.Core.Persistence;
using FluentCMS.Core.Results;
using FluentCMS.Core.Schema;
using FluentCMS.Core.Tools;
using Microsoft.Extensions.DependencyInjection;

namespace FluentCMS.Core.Builtin.Tools;

// An entry or entity write an agent proposes, shared by the entry and entity tools.
internal static class ProposedEntryWrite
{
private const string FindEntries = "Call list_entries with the content type's slug for the ids of its entries.";
private const string ReadSchema = "Call get_schema_document with the content type's slug for its fields and the values each one takes.";

// The version an agent writes and its operation in the change set commit together or not at all, as for content types, so no
// version an agent writes exists outside a change set a person reviews (AUD-N1, CHG-01).
public static async Task<ToolResult> RunAsync(
ToolInvocation call,
ChangeSetTarget target,
Func<IEntryDirectory, Task<Result<Entry>>> write,
Func<Entry, ItemKind, Result<ChangeOperation>> operation,
CancellationToken cancellationToken)
{
var services = call.Services;
var ready = await target.CheckAsync(services, cancellationToken);
if (!ready.IsSuccess)
{
return ToolResult.Failure(ready.Errors);
}

// Each directory finds its expected failures before its own unit of work, so leaving this one uncommitted undoes them all.
await using var scope = await services.GetRequiredService<IUnitOfWork>().BeginAsync(cancellationToken);

var entry = await write(services.GetRequiredService<IEntryDirectory>());
if (!entry.IsSuccess)
{
return ToolResult.Failure(entry.Errors, Hint(entry.Errors));
}

// Types are never deleted, but reading one is scope-checked, so an agent that may write a type's entries without reading it is refused here.
var type = await services.GetRequiredService<IContentTypeDirectory>().GetAsync(entry.Value.ContentType, cancellationToken);
if (!type.IsSuccess)
{
return ToolResult.Failure(type.Errors);
}

var kind = type.Value.Definition.IsEntity ? ItemKind.Entity : ItemKind.Entry;
var proposed = operation(entry.Value, kind);
if (!proposed.IsSuccess)
{
return ToolResult.Failure(proposed.Errors, Hint(proposed.Errors));
}

var set = await target.AddAsync(services.GetRequiredService<IChangeSetDirectory>(), proposed.Value, cancellationToken);
if (!set.IsSuccess)
{
return ToolResult.Failure(set.Errors);
}

await scope.CommitAsync(cancellationToken);

return ToolResult.Success(new JsonObject
{
["entry"] = ToolJson.Entry(entry.Value),
["changeSet"] = ToolJson.ChangeSet(set.Value),
["next"] = $"The {ToolJson.Name(proposed.Value.Kind)} is in change set {set.Value.Id}, which stays open for more "
+ "operations. Call submit_change_set when it is complete; checks run and a person reviews it before it is published.",
});
}

// An update is based on what is published, which is what publishing it replaces; a draft never published is still a create.
public static Result<ChangeOperation> Update(Entry entry, ItemKind kind)
{
return entry.PublishedVersion is { } published
? ChangeOperation.Create(OperationKind.Update, kind, entry.Id, published, entry.LatestVersion)
: ChangeOperation.Create(OperationKind.Create, kind, entry.Id, null, entry.LatestVersion);
}

public static string? Hint(IReadOnlyList<Error> errors)
{
return errors.FirstOrDefault()?.Code switch
{
ContentErrorCodes.VersionConflict => "Call get_entry for the latest version and its fields, then update from that version.",
ContentErrorCodes.EntryNotFound => FindEntries,
ContentErrorCodes.VersionNotFound => "Call get_entry_history for the versions the entry has.",
ContentErrorCodes.FieldsMalformed or ContentErrorCodes.FieldUnknown or ContentErrorCodes.FieldRequired or SchemaErrorCodes.ValueInvalid => ReadSchema,
ContentErrorCodes.ReferenceNotFound or ContentErrorCodes.ReferenceTypeMismatch =>
"Call list_entries with the referenced content type's slug for the ids a reference field can take.",
ContentErrorCodes.EntryNotPublished => "Only a published entry can be taken offline; get_entry shows its publishedVersion.",
EntityErrorCodes.FieldNotRelation => "Call get_content_type for the entity type's reference fields and the types they point at.",
ContentErrorCodes.EntryNotEntity => "Only entities have facts and relations; get_content_type shows whether a type is an entity type.",
SchemaErrorCodes.ContentTypeNotFound => "Call list_content_types for the slugs of the site's content types.",
_ => null,
};
}
}
80 changes: 80 additions & 0 deletions src/FluentCMS.Core/Builtin/Tools/ToolJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json.Nodes;
using FluentCMS.Core.ChangeSets;
using FluentCMS.Core.Content;
using FluentCMS.Core.Entities;
using FluentCMS.Core.Identity;
using FluentCMS.Core.Schema;

Expand Down Expand Up @@ -159,6 +160,85 @@ public static JsonObject Usage(EntryUsage usage)
};
}

public static JsonObject Facts(EntityFactSheet sheet)
{
return new JsonObject
{
["entityId"] = sheet.EntityId.ToString(),
["type"] = sheet.Type,
["version"] = sheet.Version,
["facts"] = new JsonArray([.. sheet.Facts.Select(fact => new JsonObject
{
["field"] = fact.Field,
["unsourced"] = fact.IsUnsourced,
["source"] = fact.SourceKind is null ? null : new JsonObject { ["kind"] = fact.SourceKind, ["reference"] = fact.SourceReference },
["owner"] = fact.Owner?.ToString(),
["validFrom"] = Time(fact.ValidFrom),
["validUntil"] = Time(fact.ValidUntil),
["lastVerified"] = Time(fact.LastVerified),
})]),
};
}

public static JsonObject Flag(FactFlag flag)
{
return new JsonObject
{
["entityId"] = flag.EntityId.ToString(),
["type"] = flag.Type,
["field"] = flag.Field,
["version"] = flag.Version,
["isLatest"] = flag.IsLatest,
["isPublished"] = flag.IsPublished,
};
}

public static JsonObject Relations(EntityRelations relations)
{
return new JsonObject
{
["entityId"] = relations.EntityId.ToString(),
["type"] = relations.Type,
["outgoing"] = new JsonArray([.. relations.Outgoing.Select(Relation)]),
["incoming"] = new JsonArray([.. relations.Incoming.Select(Relation)]),
};
}

public static JsonObject BlastRadius(BlastRadius radius)
{
return new JsonObject
{
["changeSetId"] = radius.ChangeSetId.ToString(),
["entries"] = new JsonArray([.. radius.Entries.Select(entry => new JsonObject
{
["entryId"] = entry.EntryId.ToString(),
["contentType"] = entry.ContentType,
["version"] = entry.Version,
["fields"] = Values(entry.Fields),
})]),
};
}

private static JsonObject Relation(EntityRelation relation)
{
return new JsonObject
{
["fromId"] = relation.FromId.ToString(),
["fromType"] = relation.FromType,
["field"] = relation.Field,
["toId"] = relation.ToId.ToString(),
["toType"] = relation.ToType,
["toField"] = relation.ToField,
["isLatest"] = relation.IsLatest,
["isPublished"] = relation.IsPublished,
};
}

private static string? Time(DateTimeOffset? time)
{
return time?.ToString("O", System.Globalization.CultureInfo.InvariantCulture);
}

private static JsonObject Operation(ChangeOperation operation)
{
return new JsonObject
Expand Down
1 change: 1 addition & 0 deletions src/FluentCMS.Core/Entities/IEntityFacts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ public sealed record FactFlag(Guid EntityId, string Type, string Field, int Vers
public static class EntityErrorCodes
{
public const string TypeNotEntity = "type_not_entity";
public const string FieldNotRelation = "field_not_relation";
}
Loading
Loading