< 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@0d738bf294e6281b936d031e1979d928007495ff
Line coverage
76%
Covered lines: 102
Uncovered lines: 32
Coverable lines: 134
Total lines: 359
Line coverage: 76.1%
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@7c4ce528870211ad6c2d2398c31ec13097fc584010/27/2025 - 15:14:42 Line coverage: 79.6% (106/133) Branch coverage: 91.4% (75/82) Total lines: 356 Tag: Kestrun/Kestrun@29b7a3fdf10e67fea44e784a49929d1eb2a8874610/27/2025 - 17:48:01 Line coverage: 75.9% (101/133) Branch coverage: 91.4% (75/82) Total lines: 356 Tag: Kestrun/Kestrun@43d8ece4bc8573b7948ce1cea478a13ad32652d011/14/2025 - 12:29:34 Line coverage: 76.1% (102/134) Branch coverage: 91.4% (75/82) Total lines: 359 Tag: Kestrun/Kestrun@5e12b09a6838e68e704cd3dc975331b9e680a626 10/15/2025 - 01:01:18 Line coverage: 75.9% (101/133) Branch coverage: 91.4% (75/82) Total lines: 356 Tag: Kestrun/Kestrun@7c4ce528870211ad6c2d2398c31ec13097fc584010/27/2025 - 15:14:42 Line coverage: 79.6% (106/133) Branch coverage: 91.4% (75/82) Total lines: 356 Tag: Kestrun/Kestrun@29b7a3fdf10e67fea44e784a49929d1eb2a8874610/27/2025 - 17:48:01 Line coverage: 75.9% (101/133) Branch coverage: 91.4% (75/82) Total lines: 356 Tag: Kestrun/Kestrun@43d8ece4bc8573b7948ce1cea478a13ad32652d011/14/2025 - 12:29:34 Line coverage: 76.1% (102/134) Branch coverage: 91.4% (75/82) Total lines: 359 Tag: Kestrun/Kestrun@5e12b09a6838e68e704cd3dc975331b9e680a626

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Host()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;
 3using Kestrun.Hosting.Options;
 4using Kestrun.Scripting;
 5
 6namespace Kestrun.Tasks;
 7
 8/// <summary>
 9/// Service to run ad-hoc Kestrun tasks in PowerShell, C#, or VB.NET, with status, result, and cancellation.
 10/// </summary>
 11/// <param name="pool">PowerShell runspace pool manager.</param>
 12/// <param name="log">Logger instance.</param>
 1513public sealed class KestrunTaskService(KestrunRunspacePoolManager pool, Serilog.ILogger log)
 14{
 1515    private readonly ConcurrentDictionary<string, KestrunTask> _tasks = new(StringComparer.OrdinalIgnoreCase);
 1516    private readonly KestrunRunspacePoolManager _pool = pool;
 1517    private readonly Serilog.ILogger _log = log;
 18
 1519    private KestrunHost Host => _pool.Host;
 20
 21    /// <summary>
 22    /// Creates a task from a code snippet without starting it.
 23    /// </summary>
 24    /// <param name="id">Optional unique task identifier. If null or empty, a new GUID will be generated.</param>
 25    /// <param name="scriptCode">The scripting language and code configuration for this task.</param>
 26    /// <param name="autoStart">Whether to start the task automatically.</param>
 27    /// <param name="name">Optional human-friendly name of the task.</param>
 28    /// <param name="description">Optional description of the task.</param>
 29    /// <returns>The unique identifier of the created task.</returns>
 30    /// <exception cref="ArgumentNullException">Thrown if scriptCode is null.</exception>
 31    /// <exception cref="InvalidOperationException">Thrown if a task with the same id already exists.</exception>
 32    public string Create(string? id, LanguageOptions scriptCode, bool autoStart, string? name, string? description = nul
 33    {
 1734        ArgumentNullException.ThrowIfNull(scriptCode);
 35
 1636        if (string.IsNullOrWhiteSpace(id))
 37        {
 1438            id = Guid.NewGuid().ToString("n");
 39        }
 1640        if (_tasks.ContainsKey(id))
 41        {
 142            throw new InvalidOperationException($"Task id '{id}' already exists.");
 43        }
 44
 1545        var progress = new ProgressiveKestrunTaskState();
 1546        var cfg = new TaskJobFactory.TaskJobConfig(Host, id, scriptCode, _pool, progress);
 1547        var work = TaskJobFactory.Create(cfg);
 1548        var cts = new CancellationTokenSource();
 1549        var task = new KestrunTask(id, scriptCode, cts)
 1550        {
 1551            Work = work,
 1552            Progress = progress,
 1553            Name = string.IsNullOrWhiteSpace(name) ? ("Task " + id) : name,
 1554            Description = string.IsNullOrWhiteSpace(description) ? string.Empty : description
 1555        };
 56
 1557        if (!_tasks.TryAdd(id, task))
 58        {
 059            throw new InvalidOperationException($"Task id '{id}' already exists.");
 60        }
 1561        if (autoStart)
 62        {
 263            _ = Start(id);
 64        }
 1565        return id;
 66    }
 67
 68    /// <summary>
 69    /// Sets or updates the name of a task.
 70    /// </summary>
 71    /// <param name="id">The task identifier.</param>
 72    /// <param name="name">The new name for the task.</param>
 73    /// <returns>True if the task was found and updated; false if not found.</returns>
 74    public bool SetTaskName(string id, string name)
 75    {
 376        if (string.IsNullOrWhiteSpace(name))
 77        {
 178            throw new ArgumentNullException(nameof(name));
 79        }
 80
 281        if (!_tasks.TryGetValue(id, out var task))
 82        {
 183            return false;
 84        }
 185        task.Name = name;
 186        return true;
 87    }
 88
 89    /// <summary>
 90    /// Sets or updates the description of a task.
 91    /// </summary>
 92    /// <param name="id">The task identifier.</param>
 93    /// <param name="description">The new description for the task.</param>
 94    /// <returns>True if the task was found and updated; false if not found.</returns>
 95    public bool SetTaskDescription(string id, string description)
 96    {
 397        if (string.IsNullOrWhiteSpace(description))
 98        {
 199            throw new ArgumentNullException(nameof(description));
 100        }
 2101        if (!_tasks.TryGetValue(id, out var task))
 102        {
 1103            return false;
 104        }
 1105        task.Description = description;
 1106        return true;
 107    }
 108
 109    /// <summary>
 110    /// Starts a previously created task by id.
 111    /// </summary>
 112    /// <param name="id">The task identifier.</param>
 113    /// <returns>True if the task was found and started; false if not found or already started.</returns>
 114    public bool Start(string id)
 115    {
 13116        if (!_tasks.TryGetValue(id, out var task))
 117        {
 1118            return false;
 119        }
 12120        if (task.State != TaskState.NotStarted || task.Runner != null)
 121        {
 1122            return false; // only start once from Created state
 123        }
 22124        task.Runner = Task.Run(async () => await ExecuteAsync(task).ConfigureAwait(false), task.TokenSource.Token);
 11125        return true;
 126    }
 127
 128    /// <summary>
 129    /// Starts a previously created task by id, and awaits its completion.
 130    /// </summary>
 131    /// <param name="id">The task identifier.</param>
 132    /// <returns>True if the task was found and started; false if not found or already started.</returns>
 133    public async Task<bool> StartAsync(string id)
 134    {
 2135        if (!_tasks.TryGetValue(id, out var task))
 136        {
 0137            return false;
 138        }
 139
 2140        if (task.State != TaskState.NotStarted || task.Runner != null)
 141        {
 1142            return false; // only start once from Created state
 143        }
 144
 145        // Launch the task asynchronously and store its runner
 2146        task.Runner = Task.Run(() => ExecuteAsync(task), task.TokenSource.Token);
 147
 148        try
 149        {
 1150            await task.Runner.ConfigureAwait(false);
 1151        }
 0152        catch (OperationCanceledException)
 153        {
 154            // Optional: handle cancellation gracefully
 0155        }
 0156        catch (Exception ex)
 157        {
 158            // Optional: handle or log errors
 0159            _log.Error(ex, "Task {Id} failed", id);
 0160        }
 161
 1162        return true;
 2163    }
 164
 165    /// <summary>
 166    /// Gets a task by id.
 167    /// </summary>
 168    /// <param name="id">The task identifier.</param>
 169    /// <returns>The task result, or null if not found.</returns>
 170    public KrTask? Get(string id)
 8171           => _tasks.TryGetValue(id, out var t) ? t.ToKrTask() : null;
 172
 173    /// <summary>
 174    /// Gets the current state for a task.
 175    /// </summary>
 176    /// <param name="id">The task identifier.</param>
 177    /// <returns>The task state, or null if not found.</returns>
 178    public TaskState? GetState(string id)
 193179        => _tasks.TryGetValue(id, out var t) ? t.State : null;
 180
 181    /// <summary>
 182    /// Gets the output object for a completed task.
 183    /// </summary>
 184    /// <param name="id">The task identifier.</param>
 185    /// <returns>The task output object, or null if not found or no output.</returns>
 186    public object? GetResult(string id)
 6187           => _tasks.TryGetValue(id, out var t) ? t.Output : null;
 188
 189    /// <summary>
 190    /// Attempts to cancel a task.
 191    /// </summary>
 192    /// <remarks>
 193    /// If the task has not been started (NotStarted) it is transitioned directly to the terminal
 194    /// Stopped state so it can be removed later and does not remain orphaned.
 195    /// </remarks>
 196    public bool Cancel(string id)
 197    {
 5198        if (!_tasks.TryGetValue(id, out var t))
 199        {
 1200            return false;
 201        }
 4202        if (t.State is TaskState.Completed or TaskState.Failed or TaskState.Stopped)
 203        {
 1204            return false;
 205        }
 206
 3207        if (t.State == TaskState.NotStarted)
 208        {
 0209            _log.Information("Cancelling task {Id} before start", id);
 210            // Transition to a terminal state so Remove() can succeed
 0211            t.State = TaskState.Stopped;
 0212            t.Progress.Cancel("Cancelled before start");
 0213            var now = DateTimeOffset.UtcNow;
 0214            t.StartedAtUtc ??= now;
 0215            t.CompletedAtUtc = now;
 0216            t.TokenSource.Cancel(); // ensure any future Start() attempt observes cancellation
 0217            return true;
 218        }
 219
 3220        _log.Information("Cancelling running task {Id}", id);
 3221        t.TokenSource.Cancel();
 3222        return true;
 223    }
 224
 225    /// <summary>
 226    /// Checks recursively if all children of a task are finished.
 227    /// </summary>
 228    /// <param name="task">The parent task to check.</param>
 229    /// <returns>True if all children are finished; false otherwise.</returns>
 230    private static bool ChildrenAreFinished(KestrunTask task)
 231    {
 17232        foreach (var child in task.Children)
 233        {
 2234            if (!ChildrenAreFinished(child))
 235            {
 0236                return false;
 237            }
 2238            if (child.State is not TaskState.Completed and not TaskState.Failed and not TaskState.Stopped)
 239            {
 1240                return false;
 241            }
 242        }
 6243        return true;
 1244    }
 245    /// <summary>
 246    /// Removes a finished task from the registry.
 247    /// </summary>
 248    /// <param name="id">The task identifier.</param>
 249    /// <returns>True if the task was found and removed; false if not found or not finished.</returns>
 250    /// <remarks>
 251    /// A task can only be removed if it is in a terminal state (Completed, Failed, Stopped)
 252    /// and all its child tasks are also in terminal states.
 253    /// </remarks>
 254    public bool Remove(string id)
 255    {
 7256        if (_tasks.TryGetValue(id, out var t))
 257        {
 6258            if (t.State is TaskState.Completed or TaskState.Failed or TaskState.Stopped)
 259            {
 5260                if (!ChildrenAreFinished(t))
 261                {
 1262                    _log.Warning("Cannot remove task {Id} because it has running child tasks", id);
 1263                    return false;
 264                }
 265
 4266                _log.Information("Removing task {Id}", id);
 4267                if (_tasks.TryRemove(id, out _))
 268                {
 269                    // Detach from parent first so recursive child removals can't mutate the list we iterate
 4270                    if (t.Parent is not null)
 271                    {
 1272                        _ = t.Parent.Children.Remove(t);
 273                    }
 274
 275                    // Take a point-in-time snapshot because recursive Remove(child.Id) will
 276                    // mutate the parent's Children collection (each child removes itself).
 4277                    if (t.Children.Count > 0)
 278                    {
 1279                        var snapshot = t.Children.ToArray();
 4280                        foreach (var child in snapshot)
 281                        {
 1282                            if (!Remove(child.Id))
 283                            {
 0284                                _log.Warning("Failed to remove child task {ChildId} of parent task {ParentId}", child.Id
 285                            }
 286                        }
 287                    }
 4288                    return true;
 289                }
 290            }
 1291            return false;
 292        }
 1293        return false;
 294    }
 295
 296    /// <summary>
 297    /// Lists all tasks with basic info.
 298    /// Does not include output or error details.
 299    /// </summary>
 300    public IReadOnlyCollection<KrTask> List()
 5301        => [.. _tasks.Values.Select(v => v.ToKrTask())];
 302
 303    /// <summary>
 304    /// Executes the task's work function and updates its state accordingly.
 305    /// </summary>
 306    /// <param name="task">The task to execute.</param>
 307    /// <returns>A task representing the asynchronous operation.</returns>
 308    private async Task ExecuteAsync(KestrunTask task)
 309    {
 12310        task.State = TaskState.Running;
 12311        task.Progress.StatusMessage = "Running";
 12312        task.StartedAtUtc = DateTimeOffset.UtcNow;
 313        try
 314        {
 12315            var result = await task.Work(task.TokenSource.Token).ConfigureAwait(false);
 10316            task.Output = result;
 10317            task.State = task.TokenSource.IsCancellationRequested ? TaskState.Stopped : TaskState.Completed;
 10318            if (task.State == TaskState.Completed)
 319            {
 9320                task.Progress.Complete("Completed");
 321            }
 1322            else if (task.State == TaskState.Stopped)
 323            {
 324                // If cancellation was requested but no exception was thrown (graceful exit), normalize progress
 1325                task.Progress.Cancel("Cancelled");
 326            }
 10327        }
 2328        catch (OperationCanceledException) when (task.TokenSource.IsCancellationRequested)
 329        {
 2330            task.State = TaskState.Stopped;
 2331            task.Progress.Cancel("Cancelled");
 2332        }
 0333        catch (TaskCanceledException) when (task.TokenSource.IsCancellationRequested)
 334        {
 335            // Some libraries throw TaskCanceledException instead of OperationCanceledException on cancellation
 0336            task.State = TaskState.Stopped;
 0337            task.Progress.Cancel("Cancelled");
 0338        }
 0339        catch (Exception ex) when (task.TokenSource.IsCancellationRequested)
 340        {
 341            // During cancellation, certain engines (e.g., PowerShell) may surface non-cancellation exceptions
 342            // such as PipelineStoppedException. If cancellation was requested, normalize to Stopped.
 0343            task.State = TaskState.Stopped;
 0344            task.Progress.Cancel("Cancelled");
 0345            _log.Information(ex, "Task {Id} cancelled with exception after cancellation was requested", task.Id);
 0346        }
 0347        catch (Exception ex)
 348        {
 0349            task.Fault = ex;
 0350            task.State = TaskState.Failed;
 0351            _log.Error(ex, "Task {Id} failed", task.Id);
 0352            task.Progress.Fail("Failed");
 0353        }
 354        finally
 355        {
 12356            task.CompletedAtUtc = DateTimeOffset.UtcNow;
 357        }
 12358    }
 359}