elsa-core/test/integration/Elsa.AI.IntegrationTests/AIWorkflowGroundingToolTests.cs
Sipke Schoorstra 0ac7184226
fix(bpmn): make document PUT If-Match and save a compare-and-swap (#8092)
* fix(bpmn): make document PUT If-Match and save a compare-and-swap

The document PUT checked If-Match, reloaded metadata, then saved through
the importer as separate steps. Two writers could both pass If-Match, and
a metadata-only save in that window was silently reverted.

Add IWorkflowDefinitionStore.TryUpdateLatestAsync — load, match, apply,
save as one critical section (memory) or ExecuteUpdate against the loaded
snapshot (EF). ImportDocumentAsync reads metadata inside that swap.
A lost race throws the same 412 the stale If-Match already returns.

Mongo/Dapper/ES stores need the same method before the endpoint is
concurrency-safe on those providers.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): resolve document-service DI and interleave test compile

Drop the cache-manager constructor dependency (only registered when
definition caching is on) and evict via DraftSaving/DraftSaved instead.
Update the export-availability stub construction and the CAS interleave
fixture to parse edited XML through the reader.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): close Greptile P1s on document PUT CAS

Require IsLatest in the EF ExecuteUpdate WHERE so a published-to-draft
loser is Conflict instead of a unique-key failure. Lock Memory CAS on
the shared MemoryStore so scoped wrappers cannot stale-overwrite.
Dispatch WorkflowDefinitionDraftSaving before the CAS persist so a
rejecting handler fails the request before commit.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): reuse published draft identity across DraftSaving and CAS

Allocate the published-to-draft id, version and created-at once before
WorkflowDefinitionDraftSaving. The compare-and-swap still rebuilds from
the just-loaded row so metadata is not frozen from the outer Find, then
reuses that announced identity and keeps handler-added custom properties.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(management): run Memory CAS lock holder off the test thread

The shared-lock test blocked inside TryUpdateLatestAsync on the test
thread, so it never reached the release signal and hung.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* test(efcore): drop the SQLite CAS harness that cannot match ExecuteUpdate

The in-memory SQLite fixture could not satisfy the store's DateTimeOffset
ORDER BY plus Data snapshot WHERE, so the winner CAS returned Conflict
before the IsLatest loser path ran. Memory already covers that contract.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* fix(bpmn): persist the prepared DraftSaving draft on document PUT CAS

Prepare the draft, dispatch WorkflowDefinitionDraftSaving so handlers can
mutate or reject, then TryUpdateLatestAsync with If-Match plus the loaded
snapshot and update: _ => draft. A metadata change in the window is 412
instead of overwriting the other write. DraftSaved still fires after CAS.

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

* docs(bpmn): align document PUT remarks with prepared-draft CAS

Co-authored-by: Sipke Schoorstra <sipkeschoorstra@outlook.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-13 01:40:43 -07:00

159 lines
8.4 KiB
C#

using System.Text.Json.Nodes;
using Elsa.AI.Abstractions.Contracts;
using Elsa.AI.Abstractions.Models;
using Elsa.Common.Models;
using Elsa.Extensions;
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Filters;
using Elsa.Workflows.Management.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.AI.IntegrationTests;
public class AIWorkflowGroundingToolTests
{
[Fact(DisplayName = "Workflow grounding tools search and return graph summaries")]
public async Task WorkflowGroundingToolsSearchAndReturnGraphSummaries()
{
var definition = new WorkflowDefinition
{
Id = "version-1",
DefinitionId = "workflow-1",
Name = "Order intake",
Description = "Receives orders",
Version = 1,
IsLatest = true,
MaterializerName = "Json",
StringData = """{ "activities": [ { "id": "a1", "type": "Elsa.Http.HttpEndpoint" } ] }"""
};
var services = new ServiceCollection();
services.AddAIHostServices();
services.AddSingleton<IWorkflowDefinitionStore>(new TestWorkflowDefinitionStore(definition));
using var provider = services.BuildServiceProvider();
var registry = provider.GetRequiredService<IAIToolRegistry>();
using var searchTool = await registry.FindAsync("workflows.search", new AIToolQuery { ActorId = "user-1" });
var searchResult = await searchTool!.ExecuteAsync(new AIToolExecutionContext
{
ActorId = "user-1",
ConversationId = "conversation-1",
Arguments = new JsonObject { ["query"] = "order" }
});
using var graphTool = await registry.FindAsync("workflows.getDefinitionGraph", new AIToolQuery { ActorId = "user-1" });
var graphResult = await graphTool!.ExecuteAsync(new AIToolExecutionContext
{
ActorId = "user-1",
ConversationId = "conversation-1",
Arguments = new JsonObject { ["id"] = "version-1" }
});
Assert.Equal(1, searchResult.Data["returned"]!.GetValue<int>());
var graph = graphResult.Data["items"]!.AsArray()[0]!.AsObject();
Assert.Equal(1, graph["activityCount"]!.GetValue<int>());
Assert.Equal("Elsa.Http.HttpEndpoint", graph["activityTypes"]!.AsArray()[0]!.GetValue<string>());
}
private class TestWorkflowDefinitionStore(params WorkflowDefinition[] definitions) : IWorkflowDefinitionStore
{
private readonly List<WorkflowDefinition> _definitions = definitions.ToList();
public Task<WorkflowDefinition?> FindAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) =>
Task.FromResult(Apply(filter).FirstOrDefault());
public Task<WorkflowDefinition?> FindAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
FindAsync(filter, cancellationToken);
public Task<Page<WorkflowDefinition>> FindManyAsync(WorkflowDefinitionFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
{
var items = Apply(filter).ToList();
return Task.FromResult(Page.Of<WorkflowDefinition>(items, items.Count));
}
public Task<Page<WorkflowDefinition>> FindManyAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, PageArgs pageArgs, CancellationToken cancellationToken = default) =>
FindManyAsync(filter, pageArgs, cancellationToken);
public Task<IEnumerable<WorkflowDefinition>> FindManyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) =>
Task.FromResult<IEnumerable<WorkflowDefinition>>(Apply(filter).ToList());
public Task<IEnumerable<WorkflowDefinition>> FindManyAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
FindManyAsync(filter, cancellationToken);
public Task<Page<WorkflowDefinitionSummary>> FindSummariesAsync(WorkflowDefinitionFilter filter, PageArgs pageArgs, CancellationToken cancellationToken = default)
{
var items = Apply(filter).Select(WorkflowDefinitionSummary.FromDefinition).ToList();
return Task.FromResult(Page.Of<WorkflowDefinitionSummary>(items, items.Count));
}
public Task<Page<WorkflowDefinitionSummary>> FindSummariesAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, PageArgs pageArgs, CancellationToken cancellationToken = default) =>
FindSummariesAsync(filter, pageArgs, cancellationToken);
public Task<IEnumerable<WorkflowDefinitionSummary>> FindSummariesAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) =>
Task.FromResult<IEnumerable<WorkflowDefinitionSummary>>(Apply(filter).Select(WorkflowDefinitionSummary.FromDefinition).ToList());
public Task<IEnumerable<WorkflowDefinitionSummary>> FindSummariesAsync<TOrderBy>(WorkflowDefinitionFilter filter, WorkflowDefinitionOrder<TOrderBy> order, CancellationToken cancellationToken = default) =>
FindSummariesAsync(filter, cancellationToken);
public Task<WorkflowDefinition?> FindLastVersionAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken) =>
Task.FromResult(Apply(filter).OrderByDescending(x => x.Version).FirstOrDefault());
public Task SaveAsync(WorkflowDefinition definition, CancellationToken cancellationToken = default)
{
_definitions.RemoveAll(x => x.Id == definition.Id);
_definitions.Add(definition);
return Task.CompletedTask;
}
public Task<WorkflowDefinitionUpdateResult> TryUpdateLatestAsync(
WorkflowDefinitionFilter filter,
Func<WorkflowDefinition, bool> matchesExpected,
Func<WorkflowDefinition, WorkflowDefinition> update,
CancellationToken cancellationToken = default)
{
var current = Apply(filter).FirstOrDefault();
if (current is null)
return Task.FromResult(WorkflowDefinitionUpdateResult.NotFound());
if (!matchesExpected(current))
return Task.FromResult(WorkflowDefinitionUpdateResult.Conflict());
var next = update(current);
_definitions.RemoveAll(x => x.Id == current.Id || x.Id == next.Id);
if (next.Id != current.Id)
{
current.IsLatest = false;
_definitions.Add(current);
}
_definitions.Add(next);
return Task.FromResult(WorkflowDefinitionUpdateResult.Updated(next));
}
public Task SaveManyAsync(IEnumerable<WorkflowDefinition> definitions, CancellationToken cancellationToken = default)
{
foreach (var definition in definitions)
_definitions.Add(definition);
return Task.CompletedTask;
}
public Task<long> DeleteAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => Task.FromResult(0L);
public Task<bool> AnyAsync(WorkflowDefinitionFilter filter, CancellationToken cancellationToken = default) => Task.FromResult(Apply(filter).Any());
public Task<long> CountDistinctAsync(CancellationToken cancellationToken = default) => Task.FromResult((long)_definitions.Select(x => x.DefinitionId).Distinct().Count());
public Task<bool> GetIsNameUnique(string name, string? definitionId = null, CancellationToken cancellationToken = default) => Task.FromResult(!_definitions.Any(x => x.Name == name && x.DefinitionId != definitionId));
private IEnumerable<WorkflowDefinition> Apply(WorkflowDefinitionFilter filter)
{
var query = _definitions.AsEnumerable();
if (!string.IsNullOrWhiteSpace(filter.Id))
query = query.Where(x => x.Id == filter.Id);
if (!string.IsNullOrWhiteSpace(filter.DefinitionId))
query = query.Where(x => x.DefinitionId == filter.DefinitionId);
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
query = query.Where(x => (x.Name?.Contains(filter.SearchTerm, StringComparison.OrdinalIgnoreCase) ?? false) || x.DefinitionId.Contains(filter.SearchTerm, StringComparison.OrdinalIgnoreCase));
return query;
}
}
}