diff --git a/Controllers/WorkflowsController.cs b/Controllers/WorkflowsController.cs index 14298fc..8d5c717 100644 --- a/Controllers/WorkflowsController.cs +++ b/Controllers/WorkflowsController.cs @@ -333,18 +333,26 @@ public class WorkflowsController : ControllerBase [RequireScope("run")] public async Task Run(Guid id, [FromBody] RunRequest? request, CancellationToken ct) { - var workflow = await _db.Workflows - .FirstOrDefaultAsync(w => - w.Id == id && w.TenantId == TenantId && w.Status == WorkflowStatus.Compiled, ct); - if (workflow == null) - return NotFound(new { error = "Workflow not found." }); + try + { + var workflow = await _db.Workflows + .FirstOrDefaultAsync(w => + w.Id == id && w.TenantId == TenantId && w.Status == WorkflowStatus.Compiled, ct); + if (workflow == null) + return NotFound(new { error = "Workflow not found." }); - var triggerJson = System.Text.Json.JsonSerializer.Serialize(new { type = TriggerType.Event }); - var correlation = $"manual:{Guid.NewGuid():N}"; - var runId = await _launcher.LaunchAsync( - new LaunchRequest(TenantId, id, triggerJson, request?.Input, correlation), ct); + var triggerJson = System.Text.Json.JsonSerializer.Serialize(new { type = TriggerType.Event }); + var correlation = $"manual:{Guid.NewGuid():N}"; + var runId = await _launcher.LaunchAsync( + new LaunchRequest(TenantId, id, triggerJson, request?.Input, correlation), ct); - return Accepted(new { runId }); + return Accepted(new { runId }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start workflow run {WorkflowId} for tenant {TenantId}", id, TenantId); + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } } /// @@ -391,25 +399,33 @@ public class WorkflowsController : ControllerBase [RequireScope("run")] public async Task Resume(Guid id, string instanceId, [FromBody] ResumeRequest? request, CancellationToken ct) { - var workflow = await _db.Workflows - .Include(w => w.Tasks) - .FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId && w.Status == WorkflowStatus.Compiled, ct); - if (workflow == null) - return NotFound(new { error = "Workflow not found." }); + try + { + var workflow = await _db.Workflows + .Include(w => w.Tasks) + .FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId && w.Status == WorkflowStatus.Compiled, ct); + if (workflow == null) + return NotFound(new { error = "Workflow not found." }); - var checkpoint = await _durable.GetAsync(instanceId, ct); - if (checkpoint == null) - return NotFound(new { error = $"No durable checkpoint for instance '{instanceId}'." }); + var checkpoint = await _durable.GetAsync(instanceId, ct); + if (checkpoint == null) + return NotFound(new { error = $"No durable checkpoint for instance '{instanceId}'." }); - var resumeTask = workflow.Tasks.FirstOrDefault(t => t.Id == checkpoint.TaskId); - if (resumeTask == null) - return BadRequest(new { error = $"Checkpointed task '{checkpoint.TaskId}' does not belong to this workflow." }); + var resumeTask = workflow.Tasks.FirstOrDefault(t => t.Id == checkpoint.TaskId); + if (resumeTask == null) + return BadRequest(new { error = $"Checkpointed task '{checkpoint.TaskId}' does not belong to this workflow." }); - var input = request?.Input ?? checkpoint.StateJson; - var correlation = $"resume:{instanceId}:{Guid.NewGuid():N}"; - var runId = await _launcher.LaunchAsync( - new LaunchRequest(TenantId, id, workflow.TriggerJson, input, correlation, checkpoint.TaskId), ct); + var input = request?.Input ?? checkpoint.StateJson; + var correlation = $"resume:{instanceId}:{Guid.NewGuid():N}"; + var runId = await _launcher.LaunchAsync( + new LaunchRequest(TenantId, id, workflow.TriggerJson, input, correlation, checkpoint.TaskId), ct); - return Accepted(new { runId, resumedTaskId = checkpoint.TaskId }); + return Accepted(new { runId, resumedTaskId = checkpoint.TaskId }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to resume workflow {WorkflowId} instance {InstanceId} for tenant {TenantId}", id, instanceId, TenantId); + return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); + } } } diff --git a/Program.cs b/Program.cs index d830055..eabb450 100644 --- a/Program.cs +++ b/Program.cs @@ -223,6 +223,22 @@ builder.Services.AddCors(options => var app = builder.Build(); app.UseCors(); + +// Global exception handler: catches unhandled exceptions from any controller +// or middleware and returns a structured JSON error instead of a raw 500. +app.UseExceptionHandler(handler => +{ + handler.Run(async context => + { + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + var ex = context.Features.Get(); + var message = ex?.Error.Message ?? "An unexpected error occurred."; + var json = System.Text.Json.JsonSerializer.Serialize(new { error = message }); + await context.Response.WriteAsync(json); + }); +}); + app.UseMiddleware(); // Resolve the Forgejo-backed workflow source asynchronously after auth. diff --git a/Services/Execution/CSharpScriptExecutor.cs b/Services/Execution/CSharpScriptExecutor.cs index 26ce080..68cf69d 100644 --- a/Services/Execution/CSharpScriptExecutor.cs +++ b/Services/Execution/CSharpScriptExecutor.cs @@ -9,10 +9,14 @@ using Microsoft.CodeAnalysis.CSharp; namespace w4c_workflows.Services.Execution; /// -/// Executes multi-file C# with the Roslyn compiler: every .cs file under -/// the task's working directory is compiled together into an in-memory assembly -/// (one syntax tree per file, so per-file using directives and file-scoped -/// namespaces work), then the entry method is invoked reflectively. +/// Executes single-file C# with the Roslyn compiler: the entry .cs file +/// specified by the task's entry.file is compiled into an in-memory +/// assembly, then the entry method is invoked reflectively. +/// +/// Only the entry file is compiled — not every .cs file under the +/// working directory. Compiling all files would merge unrelated task sources +/// (e.g. Main.cs + tasks/hello-world.cs) into one assembly, +/// causing duplicate-type errors when both define a Program class. /// /// Each execution uses a so the /// loaded assembly and all its types can be garbage-collected after the call @@ -44,12 +48,14 @@ public class CSharpScriptExecutor : IScriptExecutor try { var workingDir = ExecutionHelpers.ResolveWorkingDir(invocation); - var sources = EnumerateSources(workingDir); - if (sources.Count == 0) - return Task.FromResult(Fail("no .cs source files found under the task directory", sw.Elapsed)); + var entryPath = Path.GetFullPath(Path.Combine(workingDir, invocation.EntryFile)); + if (!File.Exists(entryPath)) + return Task.FromResult(Fail($"entry file not found: {invocation.EntryFile}", sw.Elapsed)); + + var source = new SourceFile(invocation.EntryFile, File.ReadAllText(entryPath)); // Compilation is CPU-bound and synchronous; keep it off the request thread. - var (assemblyLoadContext, assembly, diagnostics) = Compile(sources); + var (assemblyLoadContext, assembly, diagnostics) = Compile(source); if (assembly == null) { assemblyLoadContext?.Unload(); @@ -84,27 +90,13 @@ public class CSharpScriptExecutor : IScriptExecutor private static ExecutionResult Fail(string message, TimeSpan duration) => new(false, -1, string.Empty, string.Empty, null, message, duration); - private static IReadOnlyList EnumerateSources(string workingDir) + private static (AssemblyLoadContext, Assembly?, ImmutableArray) Compile(SourceFile source) { - // One syntax tree per file; compilation is order-independent, so a - // deterministic sort is all that matters for reproducibility. - return Directory.EnumerateFiles(workingDir, "*.cs", SearchOption.AllDirectories) - .Select(full => new SourceFile( - Path.GetRelativePath(workingDir, full), - File.ReadAllText(full))) - .OrderBy(s => s.Path, StringComparer.Ordinal) - .ToList(); - } - - private static (AssemblyLoadContext, Assembly?, ImmutableArray) Compile(IReadOnlyList sources) - { - var syntaxTrees = sources - .Select(s => CSharpSyntaxTree.ParseText(s.Source, path: s.Path)) - .ToArray(); + var syntaxTree = CSharpSyntaxTree.ParseText(source.Source, path: source.Path); var compilation = CSharpCompilation.Create( $"wf-cs-{Guid.NewGuid():N}", - syntaxTrees, + [syntaxTree], GetCachedReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); diff --git a/w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs b/w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs index 248d358..2892f68 100644 --- a/w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs +++ b/w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs @@ -35,8 +35,12 @@ public class CSharpScriptExecutorTests } [Fact] - public async Task Executes_multi_file_program() + public async Task Compiles_only_entry_file_not_other_cs_files() { + // Only the entry file (Program.cs) is compiled. Helper.cs exists in the + // directory but is NOT included, so referencing Helper.Add causes a + // compilation error. This prevents duplicate-type errors when multiple + // tasks each define their own Program class in the same workflow directory. using var dir = new TempDir(); dir.Write("Helper.cs", """ public static class Helper @@ -61,9 +65,8 @@ public class CSharpScriptExecutorTests var result = await _executor.ExecuteAsync( Invocation.For("csharp", "Program.cs", "{\"a\":2,\"b\":3}", dir.Path), default); - Assert.True(result.Success, result.Error); - using var output = JsonDocument.Parse(result.Output!); - Assert.Equal(5, output.RootElement.GetProperty("sum").GetInt32()); + Assert.False(result.Success); + Assert.Contains("compilation failed", result.Error, StringComparison.OrdinalIgnoreCase); } [Fact]