diff --git a/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk.Tests/Results/AnalysisResultServiceTests.cs b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk.Tests/Results/AnalysisResultServiceTests.cs new file mode 100644 index 00000000..f8e14310 --- /dev/null +++ b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk.Tests/Results/AnalysisResultServiceTests.cs @@ -0,0 +1,165 @@ +using DxWorks.ScriptBee.Analysis.Sdk.Results; +using NSubstitute; +using ScriptBee.Common; +using ScriptBee.Domain.Model.Analysis; +using ScriptBee.Domain.Model.File; + +namespace DxWorks.ScriptBee.Analysis.Sdk.Tests.Results; + +public class AnalysisResultServiceTests +{ + private static readonly Guid FixedGuid = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly DateTimeOffset FixedDate = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private readonly IScriptResultsStore _store = Substitute.For(); + private readonly IGuidProvider _guidProvider = Substitute.For(); + private readonly IDateTimeProvider _dateTimeProvider = Substitute.For(); + private readonly List _recordedSummaries = []; + + private readonly AnalysisResultService _service; + + public AnalysisResultServiceTests() + { + _guidProvider.NewGuid().Returns(FixedGuid); + _dateTimeProvider.UtcNow().Returns(FixedDate); + + _service = new AnalysisResultService( + _store, + _guidProvider, + _dateTimeProvider, + _recordedSummaries.Add + ); + } + + [Fact] + public async Task AddFileAsync_WithStreamContent_UploadsToStoreAndNotifies() + { + // Arrange + await using var content = new MemoryStream("file content"u8.ToArray()); + + // Act + var resultId = await _service.AddFileAsync( + "report.txt", + content, + cancellationToken: TestContext.Current.CancellationToken + ); + + // Assert + Assert.Equal(new ResultId(FixedGuid), resultId); + await _store + .Received(1) + .UploadFileAsync( + new FileId(FixedGuid), + content, + null, + Arg.Any() + ); + var summary = Assert.Single(_recordedSummaries); + Assert.Equal("report.txt", summary.Name); + Assert.Equal(RunResultTypes.File, summary.Type); + Assert.Equal(FixedDate, summary.CreationDate); + } + + [Fact] + public async Task AddFileAsync_WithStringContent_NotifiesWithCorrectMetadata() + { + // Act + var resultId = await _service.AddFileAsync( + "output.txt", + "hello world", + cancellationToken: TestContext.Current.CancellationToken + ); + + // Assert + Assert.Equal(new ResultId(FixedGuid), resultId); + await _store + .Received(1) + .UploadFileAsync( + new FileId(FixedGuid), + Arg.Any(), + null, + Arg.Any() + ); + var summary = Assert.Single(_recordedSummaries); + Assert.Equal("output.txt", summary.Name); + Assert.Equal(RunResultTypes.File, summary.Type); + } + + [Fact] + public async Task AddConsoleAsync_UploadsWithConsoleTypeAndDefaultName() + { + // Act + var resultId = await _service.AddConsoleAsync( + "log output", + cancellationToken: TestContext.Current.CancellationToken + ); + + // Assert + Assert.Equal(new ResultId(FixedGuid), resultId); + var summary = Assert.Single(_recordedSummaries); + Assert.Equal("ConsoleOutput", summary.Name); + Assert.Equal(RunResultTypes.Console, summary.Type); + } + + [Fact] + public async Task AddErrorAsync_UploadsWithRunErrorTypeAndDefaultName() + { + // Act + var resultId = await _service.AddErrorAsync( + "something failed", + cancellationToken: TestContext.Current.CancellationToken + ); + + // Assert + Assert.Equal(new ResultId(FixedGuid), resultId); + var summary = Assert.Single(_recordedSummaries); + Assert.Equal("RunError", summary.Name); + Assert.Equal(RunResultTypes.RunError, summary.Type); + } + + [Fact] + public async Task AddResultAsync_WithCustomType_NotifiesWithCorrectSummary() + { + // Arrange + await using var content = new MemoryStream("chart data"u8.ToArray()); + + // Act + await _service.AddResultAsync( + "MyChart", + "Chart", + content, + TestContext.Current.CancellationToken + ); + + // Assert + var summary = Assert.Single(_recordedSummaries); + Assert.Equal("MyChart", summary.Name); + Assert.Equal("Chart", summary.Type); + Assert.Equal(FixedDate, summary.CreationDate); + } + + [Fact] + public async Task AddResultAsync_WithoutCallback_DoesNotThrow() + { + // Arrange + var serviceWithoutCallback = new AnalysisResultService( + _store, + _guidProvider, + _dateTimeProvider + ); + await using var content = new MemoryStream("data"u8.ToArray()); + + // Act + var exception = await Record.ExceptionAsync(() => + serviceWithoutCallback.AddResultAsync( + "name", + RunResultTypes.File, + content, + TestContext.Current.CancellationToken + ) + ); + + // Assert + Assert.Null(exception); + } +} diff --git a/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/DxWorks.ScriptBee.Analysis.Sdk.csproj b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/DxWorks.ScriptBee.Analysis.Sdk.csproj index 496bceba..62c8a620 100644 --- a/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/DxWorks.ScriptBee.Analysis.Sdk.csproj +++ b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/DxWorks.ScriptBee.Analysis.Sdk.csproj @@ -34,6 +34,7 @@ + diff --git a/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Extensions/SdkExtensions.cs b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Extensions/SdkExtensions.cs index c83d47d2..7b6d3791 100644 --- a/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Extensions/SdkExtensions.cs +++ b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Extensions/SdkExtensions.cs @@ -40,6 +40,7 @@ private IServiceCollection AddServices() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton(); } diff --git a/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Results/AnalysisResultService.cs b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Results/AnalysisResultService.cs new file mode 100644 index 00000000..4129d3ce --- /dev/null +++ b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Results/AnalysisResultService.cs @@ -0,0 +1,69 @@ +using System.Text; +using ScriptBee.Common; +using ScriptBee.Domain.Model.Analysis; + +namespace DxWorks.ScriptBee.Analysis.Sdk.Results; + +public sealed class AnalysisResultService( + IScriptResultsStore store, + IGuidProvider guidProvider, + IDateTimeProvider dateTimeProvider, + Action? onResultAdded = null +) : IAnalysisResultService +{ + public Task AddFileAsync( + string name, + Stream content, + string resultType = RunResultTypes.File, + CancellationToken cancellationToken = default + ) => AddResultAsync(name, resultType, content, cancellationToken); + + public async Task AddFileAsync( + string name, + string content, + string resultType = RunResultTypes.File, + CancellationToken cancellationToken = default + ) + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + return await AddResultAsync(name, resultType, stream, cancellationToken); + } + + public async Task AddConsoleAsync( + string content, + string name = "ConsoleOutput", + CancellationToken cancellationToken = default + ) + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + return await AddResultAsync(name, RunResultTypes.Console, stream, cancellationToken); + } + + public async Task AddErrorAsync( + string message, + string name = "RunError", + CancellationToken cancellationToken = default + ) + { + await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(message)); + return await AddResultAsync(name, RunResultTypes.RunError, stream, cancellationToken); + } + + public async Task AddResultAsync( + string name, + string resultType, + Stream content, + CancellationToken cancellationToken = default + ) + { + var resultId = new ResultId(guidProvider.NewGuid()); + + await store.UploadFileAsync(resultId.ToFileId(), content, null, cancellationToken); + + onResultAdded?.Invoke( + new ResultSummary(resultId, name, resultType, dateTimeProvider.UtcNow()) + ); + + return resultId; + } +} diff --git a/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Results/IAnalysisResultService.cs b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Results/IAnalysisResultService.cs new file mode 100644 index 00000000..004d4ae4 --- /dev/null +++ b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Results/IAnalysisResultService.cs @@ -0,0 +1,39 @@ +using ScriptBee.Domain.Model.Analysis; + +namespace DxWorks.ScriptBee.Analysis.Sdk.Results; + +public interface IAnalysisResultService +{ + Task AddFileAsync( + string name, + Stream content, + string resultType = RunResultTypes.File, + CancellationToken cancellationToken = default + ); + + Task AddFileAsync( + string name, + string content, + string resultType = RunResultTypes.File, + CancellationToken cancellationToken = default + ); + + Task AddConsoleAsync( + string content, + string name = "ConsoleOutput", + CancellationToken cancellationToken = default + ); + + Task AddErrorAsync( + string message, + string name = "RunError", + CancellationToken cancellationToken = default + ); + + Task AddResultAsync( + string name, + string resultType, + Stream content, + CancellationToken cancellationToken = default + ); +} diff --git a/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Results/RunResultTypes.cs b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Results/RunResultTypes.cs new file mode 100644 index 00000000..05a40b28 --- /dev/null +++ b/Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk/Results/RunResultTypes.cs @@ -0,0 +1,8 @@ +namespace DxWorks.ScriptBee.Analysis.Sdk.Results; + +public static class RunResultTypes +{ + public const string File = "File"; + public const string Console = "Console"; + public const string RunError = "RunError"; +} diff --git a/analysis.Dockerfile b/analysis.Dockerfile index e17ba752..4d146a32 100644 --- a/analysis.Dockerfile +++ b/analysis.Dockerfile @@ -13,6 +13,8 @@ COPY ScriptBeeWebApp/src/Plugins ScriptBeeWebApp/src/Plugins COPY ScriptBeeWebApp/src/Analysis/Application ScriptBeeWebApp/src/Analysis/Application COPY ScriptBeeWebApp/src/Analysis/Adapters ScriptBeeWebApp/src/Analysis/Adapters +COPY Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk Integrations/sdks/csharp/DxWorks.ScriptBee.Analysis.Sdk + RUN dotnet restore ScriptBeeWebApp/src/Analysis/Adapters/Analysis.Web/Analysis.Web.csproj RUN dotnet publish ScriptBeeWebApp/src/Analysis/Adapters/Analysis.Web/Analysis.Web.csproj -c Release -o publish --no-restore diff --git a/docs/sdks/csharp_analysis_service_sdk.md b/docs/sdks/csharp_analysis_service_sdk.md index b7d1773e..454289a7 100644 --- a/docs/sdks/csharp_analysis_service_sdk.md +++ b/docs/sdks/csharp_analysis_service_sdk.md @@ -155,6 +155,70 @@ app.Run(); `AddAnalysisEndpoints()` registers all FluentValidation validators and maps every endpoint to `IEndpointRouteBuilder` automatically. +### `IAnalysisResultService` + +The high-level result service lets your analysis logic emit typed results (files, console output, errors, +or custom types) without dealing with `FileId`s, GUIDs, or stream management directly. + +```csharp +public interface IAnalysisResultService +{ + Task AddFileAsync( + string name, Stream content, + string resultType = RunResultTypes.File, + CancellationToken cancellationToken = default); + + Task AddFileAsync( + string name, string content, + string resultType = RunResultTypes.File, + CancellationToken cancellationToken = default); + + Task AddConsoleAsync( + string content, string name = "ConsoleOutput", + CancellationToken cancellationToken = default); + + Task AddErrorAsync( + string message, string name = "RunError", + CancellationToken cancellationToken = default); + + Task AddResultAsync( + string name, string resultType, Stream content, + CancellationToken cancellationToken = default); +} +``` + +Inject `IAnalysisResultService` into your use-case implementations and use the ergonomic helpers: + +```csharp +public sealed class MyRunAnalysisUseCase(IAnalysisResultService results) : IRunAnalysisUseCase +{ + public async Task RunAsync(CancellationToken cancellationToken) + { + await results.AddConsoleAsync("Analysis started", cancellationToken: cancellationToken); + + await results.AddFileAsync( + "summary.json", + """{"count": 42}""", + cancellationToken: cancellationToken + ); + + await using var csvStream = BuildCsvReport(); + await results.AddFileAsync("report.csv", csvStream, cancellationToken: cancellationToken); + } +} +``` + +The well-known type constants are available on `RunResultTypes`: + +| Constant | Value | +| :------------------------ | :----------- | +| `RunResultTypes.File` | `"File"` | +| `RunResultTypes.Console` | `"Console"` | +| `RunResultTypes.RunError` | `"RunError"` | + +You can also pass any custom string as `resultType` when calling `AddFileAsync` or `AddResultAsync` +to define your own result categories. + ## Versioning & Changelog The SDK follows [Semantic Versioning](https://semver.org/). Releases are tagged `analysis-sdk@` diff --git a/docs/sdks/index.md b/docs/sdks/index.md index 76b66c73..b95e9bdd 100644 --- a/docs/sdks/index.md +++ b/docs/sdks/index.md @@ -51,6 +51,28 @@ Every Analysis Service must support the following capabilities: --- +## Results SDK + +In addition to the request/response endpoints, both SDK implementations provide a **Results SDK** — +a high-level, write-only sink that lets analysis logic emit typed results without managing low-level +concerns such as file IDs, GUIDs, byte encoding, or store calls. + +| Result type | Produced by | Consumed by | +| :------------ | :-------------------------------- | :---------------- | +| `File` | `AddFileAsync` / `add_file` | UI file downloads | +| `Console` | `AddConsoleAsync` / `add_console` | Console log view | +| `RunError` | `AddErrorAsync` / `add_error` | Error display | +| Custom string | `AddResultAsync` / `add_result` | Plugin-defined | + +The interface is the same conceptually in both languages: + +- **C#**: `IAnalysisResultService` with `AddFileAsync`, `AddConsoleAsync`, `AddErrorAsync`, `AddResultAsync` +- **Python**: `AnalysisResultService` protocol with `add_file`, `add_console`, `add_error`, `add_result` + +See the language-specific SDK page for usage examples and DI registration details. + +--- + > **Note**: All SDK implementations target the same REST API contract defined in > [`docs/public/analysis_swagger.json`](../public/analysis_swagger.json). > The Gateway does not know or care which language or backing store your service uses. diff --git a/docs/sdks/python_analysis_service_sdk.md b/docs/sdks/python_analysis_service_sdk.md index 0ff101b2..80c3fb17 100644 --- a/docs/sdks/python_analysis_service_sdk.md +++ b/docs/sdks/python_analysis_service_sdk.md @@ -25,7 +25,7 @@ uv add scriptbee-analysis-sdk | **Validators** | Pydantic field validators for all request models | | **Abstractions** | Abstract base classes your service must implement | | **Utilities** | `FileBundler` for packaging file streams for loader plugins | -| **Registration** | `create_analysis_sdk_router()` to mount everything in one call | +| **Registration** | `create_analysis_sdk_router()` to mount everything in one call | ## Architecture @@ -46,18 +46,18 @@ uv add scriptbee-analysis-sdk Implement each abstract base class from `scriptbee_analysis_sdk.abstractions` and expose them through an `AnalysisSdkContainer`-compatible object. -| Abstract base class | Responsibility | -| :------------------------ | :------------------------------------------ | -| `RunAnalysisUseCase` | Execute a script against the loaded context | -| `GetContextUseCase` | Return current context slices | -| `GetContextGraphUseCase` | Return context as a graph (nodes + edges) | -| `LoadContextUseCase` | Load files into context via a loader plugin | -| `LinkContextUseCase` | Link loaded context via a linker plugin | -| `ClearContextUseCase` | Clear all loaded context | -| `GenerateClassesUseCase` | Stream generated model class files | -| `GetInstalledPluginsUseCase` | List installed plugins | -| `InstallPluginUseCase` | Install a plugin by id | -| `UninstallPluginUseCase` | Uninstall a plugin by id | +| Abstract base class | Responsibility | +| :--------------------------- | :------------------------------------------ | +| `RunAnalysisUseCase` | Execute a script against the loaded context | +| `GetContextUseCase` | Return current context slices | +| `GetContextGraphUseCase` | Return context as a graph (nodes + edges) | +| `LoadContextUseCase` | Load files into context via a loader plugin | +| `LinkContextUseCase` | Link loaded context via a linker plugin | +| `ClearContextUseCase` | Clear all loaded context | +| `GenerateClassesUseCase` | Stream generated model class files | +| `GetInstalledPluginsUseCase` | List installed plugins | +| `InstallPluginUseCase` | Install a plugin by id | +| `UninstallPluginUseCase` | Uninstall a plugin by id | ### Example implementation @@ -159,14 +159,86 @@ signals end-of-stream. All domain types live under `scriptbee_analysis_sdk.domain` and are Pydantic `BaseModel`s. -| Module | Types | -| :-------------- | :----------------------------------------------------------- | -| `project` | `ProjectId`, `ScriptId`, `FileId`, `InstanceId` | -| `context` | `ContextSlice`, `ContextGraphNode`, `ContextGraphEdge`, `ContextGraphResult` | -| `analysis` | `AnalysisId`, `AnalysisStatus`, `AnalysisInfo` | -| `code_generation` | `SampleCodeFile` | -| `plugins` | `PluginId`, `PluginKind`, `PluginManifest`, `Plugin`, `*PluginExtensionPoint` | -| `scripts` | `Script`, `ScriptLanguage` | +| Module | Types | +| :---------------- | :---------------------------------------------------------------------------- | +| `project` | `ProjectId`, `ScriptId`, `FileId`, `InstanceId` | +| `context` | `ContextSlice`, `ContextGraphNode`, `ContextGraphEdge`, `ContextGraphResult` | +| `analysis` | `AnalysisId`, `AnalysisStatus`, `AnalysisInfo` | +| `code_generation` | `SampleCodeFile` | +| `plugins` | `PluginId`, `PluginKind`, `PluginManifest`, `Plugin`, `*PluginExtensionPoint` | +| `scripts` | `Script`, `ScriptLanguage` | + +## Results SDK + +The `DefaultAnalysisResultService` lets your analysis logic emit typed results (files, console +output, errors, or custom types) without managing file IDs, byte encoding, or store calls directly. + +### Interface + +```python +class AnalysisResultService(Protocol): + async def add_file( + self, + name: str, + content: str | bytes, + result_type: str = ResultType.FILE, + ) -> ResultId: ... + + async def add_console(self, content: str, name: str = "ConsoleOutput") -> ResultId: ... + + async def add_error(self, message: str, name: str = "RunError") -> ResultId: ... + + async def add_result(self, name: str, result_type: str, content: str | bytes) -> ResultId: ... +``` + +### Usage + +```python +from scriptbee_analysis_sdk.results import DefaultAnalysisResultService, InMemoryScriptResultsStore +from scriptbee_analysis_sdk.domain import ResultType + +store = InMemoryScriptResultsStore() +results = DefaultAnalysisResultService(store=store) + +# emit a text file result +result_id = await results.add_file("summary.json", '{"count": 42}') + +# emit console output +await results.add_console("Analysis started") + +# emit an error +await results.add_error("Script timed out") + +# emit a custom result type +await results.add_result("chart.svg", "Chart", svg_bytes) +``` + +### Result types + +The built-in type constants are on `ResultType`: + +| Constant | Value | +| :--------------------- | :----------- | +| `ResultType.FILE` | `"File"` | +| `ResultType.CONSOLE` | `"Console"` | +| `ResultType.RUN_ERROR` | `"RunError"` | + +You can pass any custom string as `result_type` in `add_file` or `add_result` to define your own +result categories. + +### Callback hook + +`DefaultAnalysisResultService` accepts an optional `on_result_added` callback that is invoked +with a `ResultSummary` after each result is persisted. The host layer uses this to track results +without leaking infrastructure concerns into the analysis logic: + +```python +summaries = [] +results = DefaultAnalysisResultService( + store=store, + on_result_added=summaries.append, +) +``` ## Versioning & Changelog