< Summary - Kestrun — Combined Coverage

Information
Class: Kestrun.Tasks.KestrunTaskService
Assembly: Kestrun
File(s): /home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Tasks/KestrunTaskService.cs
Tag: Kestrun/Kestrun@2d87023b37eb91155071c91dd3d6a2eeb3004705
Line coverage
75%
Covered lines: 101
Uncovered lines: 32
Coverable lines: 133
Total lines: 356
Line coverage: 75.9%
Branch coverage
91%
Covered branches: 75
Total branches: 82
Branch coverage: 91.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 10/15/2025 - 01:01:18 Line coverage: 75.9% (101/133) Branch coverage: 91.4% (75/82) Total lines: 356 Tag: Kestrun/Kestrun@7c4ce528870211ad6c2d2398c31ec13097fc5840 10/15/2025 - 01:01:18 Line coverage: 75.9% (101/133) Branch coverage: 91.4% (75/82) Total lines: 356 Tag: Kestrun/Kestrun@7c4ce528870211ad6c2d2398c31ec13097fc5840

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Create(...)91.66%121295.23%
SetTaskName(...)100%44100%
SetTaskDescription(...)100%44100%
Start(...)100%66100%
StartAsync()83.33%10653.84%
Get(...)100%22100%
GetState(...)100%22100%
GetResult(...)100%22100%
Cancel(...)70%231050%
ChildrenAreFinished(...)90%101085.71%
Remove(...)94.44%181894.11%
List()100%11100%
ExecuteAsync()100%10653.12%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun/Tasks/KestrunTaskService.cs

#LineLine coverage
 1using System.Collections.Concurrent;
 2using Kestrun.Hosting.Options;
 3using Kestrun.Scripting;
 4
 5namespace Kestrun.Tasks;
 6
 7/// <summary>
 8/// Service to run ad-hoc Kestrun tasks in PowerShell, C#, or VB.NET, with status, result, and cancellation.
 9/// </summary>
 10/// <param name="pool">PowerShell runspace pool manager.</param>
 11/// <param name="log">Logger instance.</param>
 1512public sealed class KestrunTaskService(KestrunRunspacePoolManager pool, Serilog.ILogger log)
 13{
 1514    private readonly ConcurrentDictionary<string, KestrunTask> _tasks = new(StringComparer.OrdinalIgnoreCase);
 1515    private readonly KestrunRunspacePoolManager _pool = pool;
 1516    private readonly Serilog.ILogger _log = log;
 17
 18    /// <summary>
 19    /// Creates a task from a code snippet without starting it.
 20    /// </summary>
 21    /// <param name="id">Optional unique task identifier. If null or empty, a new GUID will be generated.</param>
 22    /// <param name="scriptCode">The scripting language and code configuration for this task.</param>
 23    /// <param name="autoStart">Whether to start the task automatically.</param>
 24    /// <param name="name">Optional human-friendly name of the task.</param>
 25    /// <param name="description">Optional description of the task.</param>
 26    /// <returns>The unique identifier of the created task.</returns>
 27    /// <exception cref="ArgumentNullException">Thrown if scriptCode is null.</exception>
 28    /// <exception cref="InvalidOperationException">Thrown if a task with the same id already exists.</exception>
 29    public string Create(string? id, LanguageOptions scriptCode, bool autoStart, string? name, string? description = nul
 30    {
 1731        ArgumentNullException.ThrowIfNull(scriptCode);
 32
 1633        if (string.IsNullOrWhiteSpace(id))
 34        {
 1435            id = Guid.NewGuid().ToString("n");
 36        }
 1637        if (_tasks.ContainsKey(id))
 38        {
 139            throw new InvalidOperationException($"Task id '{id}' already exists.");
 40        }
 41
 1542        var progress = new ProgressiveKestrunTaskState();
 1543        var cfg = new TaskJobFactory.TaskJobConfig(id, scriptCode, _log, _pool, progress);
 1544        var work = TaskJobFactory.Create(cfg);
 1545        var cts = new CancellationTokenSource();
 1546        var task = new KestrunTask(id, scriptCode, cts)
 1547        {
 1548            Work = work,
 1549            Progress = progress,
 1550            Name = string.IsNullOrWhiteSpace(name) ? ("Task " + id) : name,
 1551            Description = string.IsNullOrWhiteSpace(description) ? string.Empty : description
 1552        };
 53
 1554        if (!_tasks.TryAdd(id, task))
 55        {
 056            throw new InvalidOperationException($"Task id '{id}' already exists.");
 57        }
 1558        if (autoStart)
 59        {
 260            _ = Start(id);
 61        }
 1562        return id;
 63    }
 64
 65    /// <summary>
 66    /// Sets or updates the name of a task.
 67    /// </summary>
 68    /// <param name="id">The task identifier.</param>
 69    /// <param name="name">The new name for the task.</param>
 70    /// <returns>True if the task was found and updated; false if not found.</returns>
 71    public bool SetTaskName(string id, string name)
 72    {
 373        if (string.IsNullOrWhiteSpace(name))
 74        {
 175            throw new ArgumentNullException(nameof(name));
 76        }
 77
 278        if (!_tasks.TryGetValue(id, out var task))
 79        {
 180            return false;
 81        }
 182        task.Name = name;
 183        return true;
 84    }
 85
 86    /// <summary>
 87    /// Sets or updates the description of a task.
 88    /// </summary>
 89    /// <param name="id">The task identifier.</param>
 90    /// <param name="description">The new description for the task.</param>
 91    /// <returns>True if the task was found and updated; false if not found.</returns>
 92    public bool SetTaskDescription(string id, string description)
 93    {
 394        if (string.IsNullOrWhiteSpace(description))
 95        {
 196            throw new ArgumentNullException(nameof(description));
 97        }
 298        if (!_tasks.TryGetValue(id, out var task))
 99        {
 1100            return false;
 101        }
 1102        task.Description = description;
 1103        return true;
 104    }
 105
 106    /// <summary>
 107    /// Starts a previously created task by id.
 108    /// </summary>
 109    /// <param name="id">The task identifier.</param>
 110    /// <returns>True if the task was found and started; false if not found or already started.</returns>
 111    public bool Start(string id)
 112    {
 13113        if (!_tasks.TryGetValue(id, out var task))
 114        {
 1115            return false;
 116        }
 12117        if (task.State != TaskState.NotStarted || task.Runner != null)
 118        {
 1119            return false; // only start once from Created state
 120        }
 22121        task.Runner = Task.Run(async () => await ExecuteAsync(task).ConfigureAwait(false), task.TokenSource.Token);
 11122        return true;
 123    }
 124
 125    /// <summary>
 126    /// Starts a previously created task by id, and awaits its completion.
 127    /// </summary>
 128    /// <param name="id">The task identifier.</param>
 129    /// <returns>True if the task was found and started; false if not found or already started.</returns>
 130    public async Task<bool> StartAsync(string id)
 131    {
 2132        if (!_tasks.TryGetValue(id, out var task))
 133        {
 0134            return false;
 135        }
 136
 2137        if (task.State != TaskState.NotStarted || task.Runner != null)
 138        {
 1139            return false; // only start once from Created state
 140        }
 141
 142        // Launch the task asynchronously and store its runner
 2143        task.Runner = Task.Run(() => ExecuteAsync(task), task.TokenSource.Token);
 144
 145        try
 146        {
 1147            await task.Runner.ConfigureAwait(false);
 1148        }
 0149        catch (OperationCanceledException)
 150        {
 151            // Optional: handle cancellation gracefully
 0152        }
 0153        catch (Exception ex)
 154        {
 155            // Optional: handle or log errors
 0156            _log.Error(ex, "Task {Id} failed", id);
 0157        }
 158
 1159        return true;
 2160    }
 161
 162    /// <summary>
 163    /// Gets a task by id.
 164    /// </summary>
 165    /// <param name="id">The task identifier.</param>
 166    /// <returns>The task result, or null if not found.</returns>
 167    public KrTask? Get(string id)
 8168           => _tasks.TryGetValue(id, out var t) ? t.ToKrTask() : null;
 169
 170    /// <summary>
 171    /// Gets the current state for a task.
 172    /// </summary>
 173    /// <param name="id">The task identifier.</param>
 174    /// <returns>The task state, or null if not found.</returns>
 175    public TaskState? GetState(string id)
 71176        => _tasks.TryGetValue(id, out var t) ? t.State : null;
 177
 178    /// <summary>
 179    /// Gets the output object for a completed task.
 180    /// </summary>
 181    /// <param name="id">The task identifier.</param>
 182    /// <returns>The task output object, or null if not found or no output.</returns>
 183    public object? GetResult(string id)
 6184           => _tasks.TryGetValue(id, out var t) ? t.Output : null;
 185
 186    /// <summary>
 187    /// Attempts to cancel a task.
 188    /// </summary>
 189    /// <remarks>
 190    /// If the task has not been started (NotStarted) it is transitioned directly to the terminal
 191    /// Stopped state so it can be removed later and does not remain orphaned.
 192    /// </remarks>
 193    public bool Cancel(string id)
 194    {
 6195        if (!_tasks.TryGetValue(id, out var t))
 196        {
 1197            return false;
 198        }
 5199        if (t.State is TaskState.Completed or TaskState.Failed or TaskState.Stopped)
 200        {
 1201            return false;
 202        }
 203
 4204        if (t.State == TaskState.NotStarted)
 205        {
 0206            _log.Information("Cancelling task {Id} before start", id);
 207            // Transition to a terminal state so Remove() can succeed
 0208            t.State = TaskState.Stopped;
 0209            t.Progress.Cancel("Cancelled before start");
 0210            var now = DateTimeOffset.UtcNow;
 0211            t.StartedAtUtc ??= now;
 0212            t.CompletedAtUtc = now;
 0213            t.TokenSource.Cancel(); // ensure any future Start() attempt observes cancellation
 0214            return true;
 215        }
 216
 4217        _log.Information("Cancelling running task {Id}", id);
 4218        t.TokenSource.Cancel();
 4219        return true;
 220    }
 221
 222    /// <summary>
 223    /// Checks recursively if all children of a task are finished.
 224    /// </summary>
 225    /// <param name="task">The parent task to check.</param>
 226    /// <returns>True if all children are finished; false otherwise.</returns>
 227    private static bool ChildrenAreFinished(KestrunTask task)
 228    {
 17229        foreach (var child in task.Children)
 230        {
 2231            if (!ChildrenAreFinished(child))
 232            {
 0233                return false;
 234            }
 2235            if (child.State is not TaskState.Completed and not TaskState.Failed and not TaskState.Stopped)
 236            {
 1237                return false;
 238            }
 239        }
 6240        return true;
 1241    }
 242    /// <summary>
 243    /// Removes a finished task from the registry.
 244    /// </summary>
 245    /// <param name="id">The task identifier.</param>
 246    /// <returns>True if the task was found and removed; false if not found or not finished.</returns>
 247    /// <remarks>
 248    /// A task can only be removed if it is in a terminal state (Completed, Failed, Stopped)
 249    /// and all its child tasks are also in terminal states.
 250    /// </remarks>
 251    public bool Remove(string id)
 252    {
 7253        if (_tasks.TryGetValue(id, out var t))
 254        {
 6255            if (t.State is TaskState.Completed or TaskState.Failed or TaskState.Stopped)
 256            {
 5257                if (!ChildrenAreFinished(t))
 258                {
 1259                    _log.Warning("Cannot remove task {Id} because it has running child tasks", id);
 1260                    return false;
 261                }
 262
 4263                _log.Information("Removing task {Id}", id);
 4264                if (_tasks.TryRemove(id, out _))
 265                {
 266                    // Detach from parent first so recursive child removals can't mutate the list we iterate
 4267                    if (t.Parent is not null)
 268                    {
 1269                        _ = t.Parent.Children.Remove(t);
 270                    }
 271
 272                    // Take a point-in-time snapshot because recursive Remove(child.Id) will
 273                    // mutate the parent's Children collection (each child removes itself).
 4274                    if (t.Children.Count > 0)
 275                    {
 1276                        var snapshot = t.Children.ToArray();
 4277                        foreach (var child in snapshot)
 278                        {
 1279                            if (!Remove(child.Id))
 280                            {
 0281                                _log.Warning("Failed to remove child task {ChildId} of parent task {ParentId}", child.Id
 282                            }
 283                        }
 284                    }
 4285                    return true;
 286                }
 287            }
 1288            return false;
 289        }
 1290        return false;
 291    }
 292
 293    /// <summary>
 294    /// Lists all tasks with basic info.
 295    /// Does not include output or error details.
 296    /// </summary>
 297    public IReadOnlyCollection<KrTask> List()
 5298        => [.. _tasks.Values.Select(v => v.ToKrTask())];
 299
 300    /// <summary>
 301    /// Executes the task's work function and updates its state accordingly.
 302    /// </summary>
 303    /// <param name="task">The task to execute.</param>
 304    /// <returns>A task representing the asynchronous operation.</returns>
 305    private async Task ExecuteAsync(KestrunTask task)
 306    {
 12307        task.State = TaskState.Running;
 12308        task.Progress.StatusMessage = "Running";
 12309        task.StartedAtUtc = DateTimeOffset.UtcNow;
 310        try
 311        {
 12312            var result = await task.Work(task.TokenSource.Token).ConfigureAwait(false);
 9313            task.Output = result;
 9314            task.State = task.TokenSource.IsCancellationRequested ? TaskState.Stopped : TaskState.Completed;
 9315            if (task.State == TaskState.Completed)
 316            {
 8317                task.Progress.Complete("Completed");
 318            }
 1319            else if (task.State == TaskState.Stopped)
 320            {
 321                // If cancellation was requested but no exception was thrown (graceful exit), normalize progress
 1322                task.Progress.Cancel("Cancelled");
 323            }
 9324        }
 3325        catch (OperationCanceledException) when (task.TokenSource.IsCancellationRequested)
 326        {
 3327            task.State = TaskState.Stopped;
 3328            task.Progress.Cancel("Cancelled");
 3329        }
 0330        catch (TaskCanceledException) when (task.TokenSource.IsCancellationRequested)
 331        {
 332            // Some libraries throw TaskCanceledException instead of OperationCanceledException on cancellation
 0333            task.State = TaskState.Stopped;
 0334            task.Progress.Cancel("Cancelled");
 0335        }
 0336        catch (Exception ex) when (task.TokenSource.IsCancellationRequested)
 337        {
 338            // During cancellation, certain engines (e.g., PowerShell) may surface non-cancellation exceptions
 339            // such as PipelineStoppedException. If cancellation was requested, normalize to Stopped.
 0340            task.State = TaskState.Stopped;
 0341            task.Progress.Cancel("Cancelled");
 0342            _log.Information(ex, "Task {Id} cancelled with exception after cancellation was requested", task.Id);
 0343        }
 0344        catch (Exception ex)
 345        {
 0346            task.Fault = ex;
 0347            task.State = TaskState.Failed;
 0348            _log.Error(ex, "Task {Id} failed", task.Id);
 0349            task.Progress.Fail("Failed");
 0350        }
 351        finally
 352        {
 12353            task.CompletedAtUtc = DateTimeOffset.UtcNow;
 354        }
 12355    }
 356}