* 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>
159 lines
8.4 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|