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
80 changes: 47 additions & 33 deletions src/FluentCMS.Core/Builtin/Tools/DeskTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,18 @@ public sealed class DeskTools : IToolProvider
"get_desk_counts",
"Get the review desk's counts",
"Returns how many items wait in each queue of the review desk: sets awaiting review, sets whose checks failed, stale facts, "
+ "and sets published or reverted in the last seven days. Call list_desk_queue to see the items of one queue.",
+ "and sets published or reverted in the last seven days. An agent counts only the change sets it opened, so other "
+ "people's and agents' sets may wait too. Call list_desk_queue to see the items of one queue.",
ToolSchemas.Object(),
readOnly: true,
CountsAsync),
new AgentTool(
"list_desk_queue",
"List a review desk queue",
"Lists one queue of the review desk, as reviewers see it: awaiting_review and checks_failed put the set that has waited "
+ "longest first; recently_published and reverted cover the last seven days, newest first; stale_facts lists the facts "
+ "that are stale, the longest stale first. Read a set with get_change_set or get_change_set_review.",
"Lists one queue of the review desk, holding only the change sets this agent opened: awaiting_review and checks_failed "
+ "put the set that has waited longest first; recently_published and reverted cover the last seven days, newest first; "
+ "stale_facts lists the stale facts of the types this agent may read, the longest stale first. Read a set with "
+ "get_change_set or get_change_set_review.",
ToolSchemas.Object(
new ToolArgument(
"queue",
Expand All @@ -59,9 +61,9 @@ public sealed class DeskTools : IToolProvider
new AgentTool(
"get_change_set_review",
"Get a change set's review status",
"Returns where a change set stands in review: its state and since when, the reason if a person rejected it, the set it "
+ "reverts, and every move it made, in order, with who made it and when. Use it to learn whether your set was approved, "
+ "published or rejected and why; a rejected set's reason says what to change in a new set.",
"Returns where a change set this agent opened stands in review: its state and since when, the reason if a person "
+ "rejected it, the set it reverts, and every move it made, in order, with who made it and when. Use it to learn whether "
+ "your set was approved, published or rejected and why; a rejected set's reason says what to change in a new set.",
ToolSchemas.Object(ToolSchemas.Uuid("id", "The change set's id.")),
readOnly: true,
ReviewAsync),
Expand Down Expand Up @@ -139,46 +141,58 @@ private static async Task<ToolResult> ReviewAsync(ToolInvocation call, Cancellat
return ToolResult.Failure(errors);
}

var read = await call.Services.GetRequiredService<IChangeSetDirectory>().GetAsync(id!.Value, cancellationToken);
if (!read.IsSuccess)
// The set and its moves are two reads. A decision that commits between them leaves the last move ahead of the set, so both
// are read again until they agree, and the answer is one moment's.
ChangeSet set;
List<AuditEvent> moves;
var attempts = 0;
do
{
return ToolResult.Failure(read.Errors, "A change set's id comes back from the call that opened it, or from list_desk_queue.");
}

var set = read.Value;
var history = call.Services.GetRequiredService<IAuditReader>().Read(new AuditQuery { Subject = new AuditSubject("change_set", set.Id) });
var moves = new JsonArray();
DateTimeOffset? since = null;
if (history.IsSuccess)
{
await foreach (var auditEvent in history.Value.WithCancellation(cancellationToken))
var read = await call.Services.GetRequiredService<IChangeSetDirectory>().GetAsync(id!.Value, cancellationToken);
if (!read.IsSuccess)
{
if (!ChangeSetMoves.Operations.Contains(auditEvent.Operation))
{
continue;
}

since = auditEvent.Timestamp;
moves.Add(new JsonObject
{
["move"] = auditEvent.Operation["change_set.".Length..],
["by"] = Initiator(auditEvent.Initiator),
["at"] = auditEvent.Timestamp.ToUniversalTime().ToString("O"),
});
return ToolResult.Failure(read.Errors, "A change set's id comes back from the call that opened it, or from list_desk_queue.");
}

set = read.Value;
moves = await MovesAsync(call.Services, set.Id, cancellationToken);
}
while (moves.Count > 0 && ChangeSetMoves.StateAfter(moves[^1].Operation) != set.State && ++attempts < 3);

return ToolResult.Success(new JsonObject
{
["id"] = set.Id.ToString(),
["state"] = ToolJson.Name(set.State),
["stateSince"] = since?.ToUniversalTime().ToString("O"),
["stateSince"] = moves.Count > 0 ? moves[^1].Timestamp.ToUniversalTime().ToString("O") : null,
["rejectionReason"] = set.RejectionReason,
["revertsChangeSetId"] = set.RevertsChangeSetId?.ToString(),
["moves"] = moves,
["moves"] = new JsonArray([.. moves.Select(move => (JsonNode)new JsonObject
{
["move"] = move.Operation["change_set.".Length..],
["by"] = Initiator(move.Initiator),
["at"] = move.Timestamp.ToUniversalTime().ToString("O"),
})]),
});
}

private static async Task<List<AuditEvent>> MovesAsync(IServiceProvider services, Guid changeSetId, CancellationToken cancellationToken)
{
var moves = new List<AuditEvent>();
var history = services.GetRequiredService<IAuditReader>().Read(new AuditQuery { Subject = new AuditSubject("change_set", changeSetId) });
if (history.IsSuccess)
{
await foreach (var auditEvent in history.Value.WithCancellation(cancellationToken))
{
if (ChangeSetMoves.Operations.Contains(auditEvent.Operation))
{
moves.Add(auditEvent);
}
}
}

return moves;
}

private static JsonObject Summary(ChangeSetSummary set)
{
return new JsonObject
Expand Down
14 changes: 14 additions & 0 deletions src/FluentCMS.Core/ChangeSets/ChangeSetMoves.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,18 @@ public static class ChangeSetMoves
"change_set.rejected",
"change_set.reverted",
];

// The state a move leaves its set in, or null for an operation that is not a move.
public static ChangeSetState? StateAfter(string operation) => operation switch
{
Opened or "change_set.reopened" => ChangeSetState.Open,
"change_set.submitted" => ChangeSetState.ChecksRunning,
"change_set.checks_failed" => ChangeSetState.ChecksFailed,
"change_set.awaiting_review" => ChangeSetState.AwaitingReview,
"change_set.approved" => ChangeSetState.Approved,
"change_set.published" => ChangeSetState.Published,
"change_set.rejected" => ChangeSetState.Rejected,
"change_set.reverted" => ChangeSetState.Reverted,
_ => null,
};
}
67 changes: 67 additions & 0 deletions tests/FluentCMS.IntegrationTests/Desk/DeskQueueTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,73 @@ public async Task The_stale_facts_queue_lists_expired_and_unverified_facts_the_l
Assert.Equal((unverified, StaleReason.Unverified, 2), (Assert.Single(second.Items).EntityId, second.Items[0].Reason, second.Total));
}

// The read sides date and describe sets by these move names, so each move the directory makes must append one of them.
[Theory]
[InlineData(DatabaseProviders.Sqlite)]
[PostgresData]
public async Task Every_move_the_directory_makes_appends_a_known_move_that_names_the_state_it_leaves(string provider)
{
await using var scenario = await ChangeSetScenario.CreateAsync(provider);
var seen = new HashSet<string>(StringComparer.Ordinal);
var set = await scenario.OpenAsync(Create(await scenario.ContentTypeAsync("product")));
await ExpectMoveAsync(scenario, set.Id, ChangeSetState.Open, seen, before: []);

async Task MoveAsync(Guid id, ChangeSetState state, Func<IChangeSetDirectory, Task<FluentCMS.Core.Results.Result<ChangeSet>>> move, bool asPerson = true)
{
var before = await MoveEventsAsync(scenario, id);
var moved = asPerson ? await scenario.AsPersonAsync(move) : await scenario.RunAsync(move);
Assert.True(moved.IsSuccess, string.Join("; ", moved.Errors.Select(error => error.Message)));
await ExpectMoveAsync(scenario, id, state, seen, before);
}

await MoveAsync(set.Id, ChangeSetState.ChecksRunning, directory => directory.TransitionAsync(set.Id, ChangeSetState.ChecksRunning), asPerson: false);
await MoveAsync(set.Id, ChangeSetState.ChecksFailed, directory => directory.TransitionAsync(set.Id, ChangeSetState.ChecksFailed));
await MoveAsync(set.Id, ChangeSetState.Open, directory => directory.TransitionAsync(set.Id, ChangeSetState.Open), asPerson: false);
await MoveAsync(set.Id, ChangeSetState.ChecksRunning, directory => directory.TransitionAsync(set.Id, ChangeSetState.ChecksRunning), asPerson: false);
await MoveAsync(set.Id, ChangeSetState.AwaitingReview, directory => directory.TransitionAsync(set.Id, ChangeSetState.AwaitingReview));
await MoveAsync(set.Id, ChangeSetState.Approved, directory => directory.TransitionAsync(set.Id, ChangeSetState.Approved));
await MoveAsync(set.Id, ChangeSetState.Published, directory => directory.PublishAsync(set.Id));

var revert = (await scenario.AsPersonAsync(directory => directory.RevertAsync(set.Id, "Undo it."))).Value;
await MoveAsync(revert.Id, ChangeSetState.ChecksRunning, directory => directory.TransitionAsync(revert.Id, ChangeSetState.ChecksRunning));
await MoveAsync(revert.Id, ChangeSetState.AwaitingReview, directory => directory.TransitionAsync(revert.Id, ChangeSetState.AwaitingReview));
await MoveAsync(revert.Id, ChangeSetState.Approved, directory => directory.TransitionAsync(revert.Id, ChangeSetState.Approved));
var originalBefore = await MoveEventsAsync(scenario, set.Id);
await MoveAsync(revert.Id, ChangeSetState.Published, directory => directory.PublishAsync(revert.Id));
await ExpectMoveAsync(scenario, set.Id, ChangeSetState.Reverted, seen, originalBefore);

var rejected = await scenario.OpenAsync(Create(await scenario.ContentTypeAsync("category")));
await MoveAsync(rejected.Id, ChangeSetState.ChecksRunning, directory => directory.TransitionAsync(rejected.Id, ChangeSetState.ChecksRunning), asPerson: false);
await MoveAsync(rejected.Id, ChangeSetState.AwaitingReview, directory => directory.TransitionAsync(rejected.Id, ChangeSetState.AwaitingReview));
await MoveAsync(rejected.Id, ChangeSetState.Rejected, directory => directory.RejectAsync(rejected.Id, "Not needed."));

Assert.Equal(ChangeSetMoves.Operations.Order(StringComparer.Ordinal), seen.Order(StringComparer.Ordinal));
}

private static async Task ExpectMoveAsync(ChangeSetScenario scenario, Guid id, ChangeSetState state, HashSet<string> seen, IReadOnlyCollection<Guid> before)
{
var appended = (await scenario.QueryAsync(context => context.AuditEvents
.AsNoTracking()
.Where(row => row.SubjectType == "change_set" && row.SubjectId == id && row.Operation.StartsWith("change_set."))
.ToListAsync()))
.Where(row => !before.Contains(row.Id) && !row.Operation.StartsWith("change_set.operation_", StringComparison.Ordinal))
.ToList();

var move = Assert.Single(appended);
Assert.Contains(move.Operation, ChangeSetMoves.Operations);
Assert.Equal(state, ChangeSetMoves.StateAfter(move.Operation));
seen.Add(move.Operation);
}

private static async Task<List<Guid>> MoveEventsAsync(ChangeSetScenario scenario, Guid id)
{
return await scenario.QueryAsync(context => context.AuditEvents
.AsNoTracking()
.Where(row => row.SubjectType == "change_set" && row.SubjectId == id)
.Select(row => row.Id)
.ToListAsync());
}

private static async Task<ChangeSet> AwaitingReviewAsync(ChangeSetScenario scenario, string slug)
{
var set = await scenario.OpenAsync(Create(await scenario.ContentTypeAsync(slug)));
Expand Down
24 changes: 20 additions & 4 deletions tests/FluentCMS.IntegrationTests/Mcp/ConnectedAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ internal sealed class ConnectedAgent(
AdminHost host,
Guid administrator,
AgentCredential credential,
McpClient client) : IAsyncDisposable
McpClient client,
bool ownsHost = true) : IAsyncDisposable
{
public AgentCredential Credential { get; } = credential;

Expand Down Expand Up @@ -101,12 +102,27 @@ public async Task<T> QueryAsync<T>(Func<FluentCmsDbContext, Task<T>> query)
return await query(scope.ServiceProvider.GetRequiredService<FluentCmsDbContext>());
}

// Another agent on the same host, with a credential of its own, for what one agent must not see of another's work. Disposing it
// leaves the host running.
public async Task<ConnectedAgent> AnotherAgentAsync(string displayName)
{
var issued = await McpAgents.IssueAsync(host, administrator, McpAgents.Credential(DateTimeOffset.UtcNow, displayName) with
{
Scope = AgentScope.Create([Site.Default.Id], null, null, [AgentOperation.Read, AgentOperation.Create, AgentOperation.Update]).Value,
});
var other = await McpAgents.ConnectOverHttpAsync(host, issued.Key);
return new ConnectedAgent(install, database, host, administrator, issued.Credential, other, ownsHost: false);
}

public async ValueTask DisposeAsync()
{
await client.DisposeAsync();
await host.DisposeAsync();
await database.DisposeAsync();
install.Dispose();
if (ownsHost)
{
await host.DisposeAsync();
await database.DisposeAsync();
install.Dispose();
}
}

private static string Text(CallToolResult result)
Expand Down
Loading
Loading