diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..e362424
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,57 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ name: test (${{ matrix.os }}, ${{ matrix.browser }})
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ browser: [chromium, firefox, webkit]
+ runs-on: ${{ matrix.os }}
+ defaults:
+ run:
+ shell: pwsh
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-dotnet@v5
+ with:
+ global-json-file: global.json
+ - run: dotnet build
+ # Playwright advises against caching the browsers.
+ - run: ./tests/SqlRunboard.Gui.Tests/bin/Debug/net10.0/playwright.ps1 install ${{ matrix.browser }} ${{ runner.os == 'Linux' && '--with-deps' || '' }}
+ - run: dotnet test --no-build
+ env:
+ BROWSER: ${{ matrix.browser }}
+
+ # The integration suite against a real SQL Server, on Linux only (docs/SPEC.md "Tests").
+ # The SA password is a throwaway for this run's container, which only this job can reach.
+ integration:
+ runs-on: ubuntu-latest
+ services:
+ sql:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ env:
+ ACCEPT_EULA: "Y"
+ MSSQL_SA_PASSWORD: Rb-${{ github.run_id }}-${{ github.run_attempt }}-Sql9
+ ports:
+ - 1433:1433
+ options: >-
+ --health-cmd "/opt/mssql-tools18/bin/sqlcmd -C -b -S localhost -U sa -P $MSSQL_SA_PASSWORD -Q 'SELECT 1'"
+ --health-interval 10s --health-timeout 5s --health-retries 20
+ defaults:
+ run:
+ shell: pwsh
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-dotnet@v5
+ with:
+ global-json-file: global.json
+ - run: dotnet test --project tests/SqlRunboard.Integration.Tests
+ env:
+ SQLRUNBOARD_TEST_SQL: Server=localhost,1433;User ID=sa;Password=Rb-${{ github.run_id }}-${{ github.run_attempt }}-Sql9;TrustServerCertificate=true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..3457bba
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,77 @@
+name: Release
+
+# Push a tag such as v0.1.0 to publish a release.
+on:
+ push:
+ tags: ['v*']
+
+permissions:
+ contents: write
+
+jobs:
+ publish:
+ name: publish (${{ matrix.rid }})
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - { rid: win-x64, os: windows-latest }
+ # macOS runner: the SDK gives the apphost an ad-hoc signature, which Apple silicon needs.
+ - { rid: osx-arm64, os: macos-latest }
+ - { rid: linux-x64, os: ubuntu-latest }
+ - { rid: linux-arm64, os: ubuntu-latest }
+ runs-on: ${{ matrix.os }}
+ defaults:
+ run:
+ shell: pwsh
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-dotnet@v5
+ with:
+ global-json-file: global.json
+ - name: Publish and pack
+ run: |
+ $rid = '${{ matrix.rid }}'
+ $out = "publish/$rid"
+ foreach ($project in 'sqlrunboard', 'sqlrunboard-desktop') {
+ dotnet publish "src/$project" -c Release -r $rid --self-contained `
+ -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=None -o $out
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
+ }
+ # Pack only the two executables (the win-x64 output also has native .pdb files).
+ $ext = $rid.StartsWith('win') ? '.exe' : ''
+ $files = "sqlrunboard$ext", "sqlrunboard-desktop$ext"
+ $name = "sqlrunboard-${{ github.ref_name }}-$rid"
+ # tar keeps the Unix execute bit. Windows users expect a zip.
+ if ($ext) { Compress-Archive -Path ($files | ForEach-Object { "$out/$_" }) -DestinationPath "$name.zip" }
+ else { tar -czf "$name.tar.gz" -C $out @files }
+ if ($LASTEXITCODE) { exit $LASTEXITCODE }
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ${{ matrix.rid }}
+ path: sqlrunboard-*
+ if-no-files-found: error
+
+ release:
+ needs: publish
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v5
+ with:
+ path: dist
+ merge-multiple: true
+ - name: Create release
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ $notes = @'
+ Each archive contains `sqlrunboard` (opens the GUI in your browser) and `sqlrunboard-desktop` (opens the GUI in its own window). The executables are self-contained: you do not need to install .NET.
+
+ The executables are not code signed.
+
+ - macOS: the system quarantines downloaded files. After you extract the archive, run `xattr -d com.apple.quarantine sqlrunboard sqlrunboard-desktop` in the folder.
+ - Windows: SmartScreen can show "Windows protected your PC". Click "More info", then "Run anyway".
+ - Linux: `sqlrunboard-desktop` needs WebKitGTK (for example `libwebkit2gtk-4.1-0`) on the computer.
+ '@
+ gh release create '${{ github.ref_name }}' dist/* --repo '${{ github.repository }}' --title '${{ github.ref_name }}' --notes $notes --generate-notes
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..e3aac5e
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,7 @@
+
+
+ net10.0
+ enable
+ enable
+
+
diff --git a/SqlRunboard.slnx b/SqlRunboard.slnx
new file mode 100644
index 0000000..251eaa4
--- /dev/null
+++ b/SqlRunboard.slnx
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..1640468
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,14 @@
+# Local SQL Server for the integration tests (docs/SPEC.md "Tests"). The image is amd64 only:
+# on Apple Silicon, turn on Rosetta in Docker Desktop.
+# MSSQL_SA_PASSWORD= docker compose up -d
+# SQLRUNBOARD_TEST_SQL="Server=localhost,1433;User ID=sa;Password=;TrustServerCertificate=true"
+# dotnet test --project tests/SqlRunboard.Integration.Tests
+services:
+ sql:
+ image: mcr.microsoft.com/mssql/server:2022-latest
+ platform: linux/amd64
+ environment:
+ ACCEPT_EULA: "Y"
+ MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:?set MSSQL_SA_PASSWORD}
+ ports:
+ - "${SQL_PORT:-1433}:1433"
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..1d364c6
--- /dev/null
+++ b/global.json
@@ -0,0 +1,9 @@
+{
+ "sdk": {
+ "version": "10.0.100",
+ "rollForward": "latestFeature"
+ },
+ "test": {
+ "runner": "Microsoft.Testing.Platform"
+ }
+}
diff --git a/src/SqlRunboard.Core/Batch.cs b/src/SqlRunboard.Core/Batch.cs
new file mode 100644
index 0000000..7424439
--- /dev/null
+++ b/src/SqlRunboard.Core/Batch.cs
@@ -0,0 +1,55 @@
+using System.Text.Json.Serialization;
+
+namespace SqlRunboard.Core;
+
+public enum Auth { Integrated, SqlServer, EntraInteractive, EntraDefault }
+
+public enum Encrypt { Mandatory, Optional, Strict }
+
+/// The target block of the batch file. See docs/SPEC.md "Batch".
+public sealed record BatchTarget
+{
+ public string? Server { get; init; }
+ public string? Database { get; init; }
+ public Auth Auth { get; init; } = Auth.Integrated;
+ public string? User { get; init; }
+ public string? PasswordVariable { get; init; }
+ public Encrypt Encrypt { get; init; } = Encrypt.Mandatory;
+ public bool TrustServerCertificate { get; init; }
+ public string ExtraConnectionString { get; init; } = "";
+}
+
+/// The batch settings, with the defaults from docs/SPEC.md "Batch".
+public sealed record BatchSettings
+{
+ public string? Folder { get; init; }
+ public string Filters { get; init; } = "*.sql";
+ public bool Recurse { get; init; }
+ public int ParallelLimit { get; init; } = 4;
+ public bool StopOnFirstFailure { get; init; }
+ public bool ConfirmBeforeRun { get; init; }
+ public int? TimeoutSeconds { get; init; }
+ public BatchTarget Target { get; init; } = new();
+
+ /// The ranges of docs/SPEC.md "Batch". JSON input can also give null for the non-null properties.
+ public bool IsValid => this is { ParallelLimit: >= 1 and <= 64, TimeoutSeconds: null or >= 1, Filters: not null, Target: not null };
+}
+
+///
+/// One file of the batch. Path is relative to the folder, with /. Include false: a cleared check box.
+/// State: New or Missing until the next save (docs/SPEC.md "Script order"). The batch file never has it.
+///
+public sealed record ScriptEntry(string Path, bool Include, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] ScriptState? State = null);
+
+public enum ScriptState { New, Missing }
+
+///
+/// One entry in the side list. File is null for an unsaved batch. Scripts is the start order. Run is the current run, or null.
+/// FileError: the app could not read the batch file at the start (for example "File not found"). The entry stays until you remove it.
+/// Version: each change of the batch gets a higher number, so the page can drop a state that is older than the one it has.
+///
+public sealed record Batch(int Id, string Name, string? File, bool Dirty, BatchSettings Settings, IReadOnlyList Scripts, RunState? Run = null,
+ [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? FileError = null, long Version = 0);
+
+/// The move commands of docs/SPEC.md "Script order". One item at a time.
+public enum MoveTo { Up, Down, Top, Bottom }
diff --git a/src/SqlRunboard.Core/BatchFile.cs b/src/SqlRunboard.Core/BatchFile.cs
new file mode 100644
index 0000000..751ff6c
--- /dev/null
+++ b/src/SqlRunboard.Core/BatchFile.cs
@@ -0,0 +1,43 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SqlRunboard.Core;
+
+/// The file on disk changed after the app loaded or saved it. The user must choose: overwrite it, or load it again.
+public sealed class BatchFileChangedException(string file) : IOException($"The file changed on disk: {file}");
+
+/// The batch file, schema version 1. See docs/SPEC.md "Batch". It holds no secrets: the password is never in BatchSettings.
+public static class BatchFile
+{
+ public const string Extension = ".sqlrunboard.json";
+
+ // A key that is not in the file gets the spec default.
+ sealed record Json(int Version, string? Folder = null, string Filters = "*.sql", bool Recurse = false, int ParallelLimit = 4, bool StopOnFirstFailure = false,
+ bool ConfirmBeforeRun = false, int? TimeoutSeconds = null, BatchTarget? Target = null, IReadOnlyList? Scripts = null);
+
+ static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { WriteIndented = true, Converters = { new JsonStringEnumConverter() } };
+
+ public static bool HasExtension(string path) => path.EndsWith(Extension, StringComparison.OrdinalIgnoreCase);
+
+ /// The batch name: the file name without .sqlrunboard.json.
+ public static string NameOf(string file) => Path.GetFileName(file)[..^Extension.Length];
+
+ public static string Format(BatchSettings s, IReadOnlyList scripts) =>
+ JsonSerializer.Serialize(new Json(1, s.Folder, s.Filters, s.Recurse, s.ParallelLimit, s.StopOnFirstFailure, s.ConfirmBeforeRun, s.TimeoutSeconds, s.Target, scripts), Options);
+
+ /// Throws InvalidDataException for an incorrect file.
+ public static (BatchSettings Settings, IReadOnlyList Scripts) Parse(string text)
+ {
+ Json? j;
+ try { j = JsonSerializer.Deserialize(text, Options); }
+ catch (JsonException e) { throw new InvalidDataException($"Not a batch file: {e.Message}"); }
+ if (j is not { Version: 1 }) throw new InvalidDataException("Not a batch file of version 1.");
+ var settings = new BatchSettings
+ {
+ Folder = j.Folder, Filters = j.Filters, Recurse = j.Recurse, ParallelLimit = j.ParallelLimit, StopOnFirstFailure = j.StopOnFirstFailure,
+ ConfirmBeforeRun = j.ConfirmBeforeRun, TimeoutSeconds = j.TimeoutSeconds, Target = j.Target ?? new(),
+ };
+ if (!settings.IsValid || j.Scripts?.Any(s => s?.Path is null) == true) throw new InvalidDataException("The batch file has an incorrect setting.");
+ return (settings, [.. (j.Scripts ?? []).Select(s => new ScriptEntry(s.Path, s.Include))]); // a State in the file is not ours
+ }
+}
diff --git a/src/SqlRunboard.Core/BatchList.cs b/src/SqlRunboard.Core/BatchList.cs
new file mode 100644
index 0000000..ebcf257
--- /dev/null
+++ b/src/SqlRunboard.Core/BatchList.cs
@@ -0,0 +1,449 @@
+using System.Threading.Channels;
+
+namespace SqlRunboard.Core;
+
+/// A change for the page. Kind is the SSE event type; Data is the JSON payload.
+public sealed record Change(string Kind, object Data);
+
+///
+/// The side list: all batches, in the order you added them. The server keeps this state; the page shows it.
+/// Each subscriber gets the full state, then every change after it.
+/// It also runs the batches: the current run of each batch is a RunEngine with its own queue and parallel limit, and many batches can run at once.
+/// Each run writes a log folder next to the batch file, or for an unsaved batch in appDataFolder (no log if it is null).
+///
+public sealed class BatchList(ISqlExecutor executor, TimeProvider time, string? appDataFolder = null)
+{
+ readonly Lock gate = new();
+ readonly List batches = [];
+ readonly Dictionary runs = []; // the current run of each batch
+ readonly Dictionary shown = []; // the run that each batch shows: its messages stay when the run ends
+ readonly List> subscribers = [];
+ readonly Dictionary passwords = []; // in memory only: never in a Batch, a file or an event
+ readonly Dictionary loaded = []; // batch id -> the file text that the app last read or wrote
+ readonly HashSet finishing = []; // one per Running script: ends after Finish wrote its state and log
+ int lastId, lastUntitled;
+ long version;
+ string[] files = [];
+
+ ///
+ /// Called (under the lock) with the batch file paths in side-list order each time they change. The app keeps them in its settings,
+ /// so Restore can bring the side list back after a restart. Unsaved batches have no file and do not come back.
+ ///
+ public Action>? FilesChanged { get; set; }
+
+ ///
+ /// The start of the app: adds the batch files in this order. A file that cannot be read stays in the list with a FileError.
+ ///
+ public void Restore(IEnumerable paths)
+ {
+ lock (gate)
+ foreach (var file in paths.Where(p => Path.IsPathFullyQualified(p) && BatchFile.HasExtension(p)).Distinct(FileNames))
+ {
+ var b = new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: []);
+ try { b = LastRun(Load(b)); }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException or InvalidDataException)
+ {
+ b = b with { FileError = e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message };
+ }
+ Put(b);
+ }
+ }
+
+ /// Batch file paths: Windows and macOS file names ignore the letter case. Script paths in a batch do not (docs/SPEC.md "Batch").
+ static readonly StringComparer FileNames = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
+
+ /// The server keeps the last 10,000 messages of each attempt. run.log has all of them.
+ public const int MaxMessages = 10_000;
+
+ public Batch NewBatch()
+ {
+ lock (gate)
+ {
+ return Put(new Batch(++lastId, $"Untitled {++lastUntitled}", File: null, Dirty: true, new BatchSettings(), Scripts: []));
+ }
+ }
+
+ /// Changes the settings and scans the folder again. Null if no batch has this id.
+ public Batch? UpdateSettings(int id, BatchSettings settings)
+ {
+ lock (gate)
+ {
+ // ponytail: the scan runs under the lock; move it out if a slow network folder blocks the page.
+ return Replace(id, b => b with { Settings = settings, Scripts = Merge(b.Scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)), Run = EndRun(b, settings) });
+ }
+ }
+
+ ///
+ /// Test connection, and the logon check before a run. A password replaces the kept one; null uses the kept one.
+ /// A SQL Server logon with no password gives LogonFailed with no Error and no connection: ask for it.
+ /// The server keeps a password that works and forgets one that the server rejects. Null if no batch has this id.
+ ///
+ public async Task LogonAsync(int id, string? password, CancellationToken ct)
+ {
+ BatchTarget target;
+ lock (gate)
+ {
+ if (Find(id) is not { } b) return null;
+ target = b.Settings.Target;
+ password ??= passwords.GetValueOrDefault(id);
+ }
+ if (target.Auth == Auth.SqlServer && password is null) return new SqlLogon(null, null, null, LogonFailed: true);
+ var logon = await executor.LogonAsync(new SqlConnectionSettings(target, password), ct);
+ lock (gate)
+ {
+ if (Find(id)?.Settings.Target != target) return logon; // the target changed meanwhile: keep nothing
+ if (logon.LogonFailed) passwords.Remove(id);
+ else if (logon.Error is null && password is not null) passwords[id] = password;
+ }
+ return logon;
+ }
+
+ ///
+ /// Call under the lock. A change to the target or the file scan ends the current run: reruns do nothing until the next Run.
+ /// The other settings apply to the next reruns. A run with Queued or Running scripts does not end.
+ ///
+ RunState? EndRun(Batch b, BatchSettings settings)
+ {
+ var (a, run) = (b.Settings, b.Run);
+ var newTarget = a.Target with { PasswordVariable = null } != settings.Target with { PasswordVariable = null };
+ if (newTarget) passwords.Remove(b.Id); // a new target asks for the password again
+ var ends = newTarget || Folder(b.File, a.Folder) != Folder(b.File, settings.Folder) || (a.Filters, a.Recurse) != (settings.Filters, settings.Recurse);
+ return ends && run is { Ended: false, Active: false } && runs.Remove(b.Id) ? run with { Ended = true } : run;
+ }
+
+ /// The full folder path, to compare: "scripts", "scripts/" and "./scripts" are the same folder.
+ static string? Folder(string? batchFile, string? folder) =>
+ FolderPath(batchFile, folder) is { } f && !string.IsNullOrWhiteSpace(f) ? Path.TrimEndingDirectorySeparator(Path.GetFullPath(f)) : null;
+
+ /// Sets or clears the check box of one file. Null if no batch has this id.
+ public Batch? SetInclude(int id, string path, bool include)
+ {
+ lock (gate)
+ return Replace(id, b => b with { Scripts = [.. b.Scripts.Select(s => s.Path == path ? s with { Include = include } : s)] });
+ }
+
+ ///
+ /// Moves one script. During a run it moves a Queued script in the queue of that run only. Otherwise it changes the start order.
+ /// Null if no batch has this id.
+ ///
+ public Batch? Move(int id, string path, MoveTo to)
+ {
+ lock (gate)
+ {
+ if (Find(id) is not { } b) return null;
+ if (b.Run?.Active == true)
+ {
+ runs.GetValueOrDefault(id)?.Move(path, to);
+ return Find(id);
+ }
+ var scripts = b.Scripts.ToList();
+ return MoveIn(scripts, s => s.Path == path, to) ? Replace(id, x => x with { Scripts = scripts }) : b;
+ }
+ }
+
+ /// Moves a batch in the side list. SSE event "order" with the ids in the new order. False if no batch has this id.
+ public bool MoveBatch(int id, MoveTo to)
+ {
+ lock (gate)
+ {
+ if (Find(id) is null) return false;
+ if (MoveIn(batches, b => b.Id == id, to)) Publish(new Change("order", new { ids = batches.Select(b => b.Id).ToArray() }));
+ return true;
+ }
+ }
+
+ /// "Sort by file name": the start order becomes the natural sort. Null if no batch has this id.
+ public Batch? SortByFileName(int id)
+ {
+ lock (gate)
+ return Replace(id, b => b with { Scripts = [.. b.Scripts.OrderBy(s => s.Path, FileScan.NaturalOrder).ThenBy(s => s.Path, StringComparer.Ordinal)] });
+ }
+
+ /// Moves the first match. False if there is no match or it is already at that end.
+ internal static bool MoveIn(List list, Predicate match, MoveTo to)
+ {
+ var i = list.FindIndex(match);
+ if (i < 0) return false;
+ var item = list[i];
+ list.RemoveAt(i);
+ var j = to switch { MoveTo.Top => 0, MoveTo.Bottom => list.Count, MoveTo.Up => Math.Max(0, i - 1), _ => Math.Min(list.Count, i + 1) };
+ list.Insert(j, item);
+ return i != j;
+ }
+
+ ///
+ /// Writes the batch file. Path null: Save, to the current file. Otherwise Save as, to that full path; the app adds .sqlrunboard.json if it is not there.
+ /// Throws BatchFileChangedException if the file on disk is not the version that the app loaded, unless overwrite is true. Null if no batch has this id.
+ ///
+ public Batch? Save(int id, string? path, bool overwrite)
+ {
+ if (path is not null && !Path.IsPathFullyQualified(path)) throw new ArgumentException($"Type a full path: {path}");
+ lock (gate)
+ {
+ if (Find(id) is not { } b) return null;
+ var file = path is null ? b.File ?? throw new ArgumentException("The batch has no file. Use Save as.")
+ : BatchFile.HasExtension(path) ? Path.GetFullPath(path) : Path.GetFullPath(path + BatchFile.Extension);
+ // ponytail: Save as over another existing file overwrites it without a question; ask first if users lose files that way.
+ if (!overwrite && file == b.File && File.Exists(file) && File.ReadAllText(file) != loaded.GetValueOrDefault(id)) throw new BatchFileChangedException(file);
+ var settings = b.Settings with { Folder = Rebase(b.File, file, b.Settings.Folder) };
+ // Save removes the Missing entries and clears the New marks.
+ List scripts = [.. b.Scripts.Where(s => s.State != ScriptState.Missing).Select(s => s with { State = null })];
+ var text = BatchFile.Format(settings, scripts);
+ File.WriteAllText(file, text);
+ loaded[id] = text;
+ return Put(b with { Name = BatchFile.NameOf(file), File = file, Dirty = false, Settings = settings, Scripts = scripts });
+ }
+ }
+
+ ///
+ /// Adds a batch file to the list, or gives the entry that already has this file. The path must be full and end with .sqlrunboard.json.
+ /// Throws FileNotFoundException, or InvalidDataException for an incorrect file.
+ ///
+ public Batch Open(string path)
+ {
+ if (!Path.IsPathFullyQualified(path) || !BatchFile.HasExtension(path)) throw new ArgumentException($"Select a full path to a {BatchFile.Extension} file: {path}");
+ var file = Path.GetFullPath(path);
+ lock (gate)
+ {
+ if (batches.Find(b => FileNames.Equals(b.File, file)) is { } open) return open;
+ return Put(Load(new Batch(++lastId, BatchFile.NameOf(file), file, Dirty: false, new BatchSettings(), Scripts: [])));
+ }
+ }
+
+ ///
+ /// "Load again": replaces the batch with its file on disk. Null if no batch has this id.
+ /// Throws InvalidOperationException while the run has Queued or Running scripts: the run locks the settings.
+ ///
+ public Batch? Reload(int id)
+ {
+ lock (gate)
+ {
+ if (Find(id) is not { } b) return null;
+ if (b.File is null) throw new ArgumentException("The batch has no file.");
+ if (b.Run?.Active == true) throw new InvalidOperationException("The run locks the batch. Load it again after the run.");
+ return Put(Load(b));
+ }
+ }
+
+ // Call under the lock. Reads b.File and scans its folder.
+ Batch Load(Batch b)
+ {
+ var text = File.ReadAllText(b.File!);
+ var (settings, scripts) = BatchFile.Parse(text);
+ loaded[b.Id] = text;
+ return b with { Dirty = false, Settings = settings, Scripts = Merge(scripts, FileScan.Scan(FolderPath(b.File, settings.Folder), settings.Filters, settings.Recurse)), Run = EndRun(b, settings), FileError = null };
+ }
+
+ ///
+ /// Call under the lock, at the start. The batch gets the run in its newest summary.csv. The rerun commands work on that run,
+ /// in its log folder, unless the server or database in the file is not the target of the batch: then the run has ended.
+ ///
+ Batch LastRun(Batch b)
+ {
+ if (RunLog.Newest(Path.GetDirectoryName(b.File)!, b.Name) is not { } folder) return b;
+ List scripts;
+ string server, database;
+ try { (scripts, server, database) = RunLog.ReadSummary(folder); }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException or FormatException or ArgumentException or OverflowException) { return b; } // no last run to show
+ var start = scripts.SelectMany(s => s.Attempts).Select(a => a.Start).DefaultIfEmpty(time.GetUtcNow()).Min();
+ var target = b.Settings.Target;
+ var ended = scripts.Count > 0 && (server != (target.Server ?? "") || database != (target.Database ?? ""));
+ if (!ended)
+ {
+ var run = runs[b.Id] = shown[b.Id] = new RunEngine(this, b.Id, start, FolderPath(b.File, b.Settings.Folder) ?? "", b.Settings, RunLog.Open(folder, time, text => Warn(b.Id, text)));
+ run.Restore(scripts);
+ }
+ return b with { Run = new RunState(start, time.GetUtcNow(), scripts, [], ended) };
+ }
+
+ /// "Remove from list". The file stays on disk. The run of the batch is cancelled. False if no batch has this id.
+ public bool Remove(int id)
+ {
+ CancelAll(id); // outside the lock: see Cancel
+ lock (gate)
+ {
+ if (batches.RemoveAll(b => b.Id == id) == 0) return false;
+ loaded.Remove(id);
+ passwords.Remove(id);
+ runs.Remove(id);
+ shown.Remove(id);
+ Publish(new Change("remove", new { id }));
+ return true;
+ }
+ }
+
+ ///
+ /// The folder for a batch file at newFile. A relative folder (or any folder of an unsaved batch) becomes relative to newFile, with /.
+ /// It stays absolute if no relative path is possible (another Windows drive). An absolute folder of a saved batch is your choice and stays.
+ ///
+ static string? Rebase(string? oldFile, string newFile, string? folder)
+ {
+ if (string.IsNullOrWhiteSpace(folder) || (oldFile is not null && Path.IsPathRooted(folder))) return folder;
+ var rel = Path.GetRelativePath(Path.GetDirectoryName(newFile)!, Path.GetFullPath(FolderPath(oldFile, folder)!));
+ return Path.IsPathRooted(rel) ? rel : rel.Replace('\\', '/');
+ }
+
+ // Call under the lock.
+ Batch? Replace(int id, Func change)
+ {
+ if (Find(id) is not { } b) return null;
+ if (b.Run?.Active == true) return b; // the run locks the settings and the check boxes
+ return Put(change(b) with { Dirty = true });
+ }
+
+ // Call under the lock. Stores the batch with the next version (a new batch goes at the end of the list) and publishes it.
+ Batch Put(Batch b)
+ {
+ b = b with { Version = ++version };
+ var i = batches.FindIndex(x => x.Id == b.Id);
+ if (i < 0) batches.Add(b); else batches[i] = b;
+ Publish(new Change("batch", b));
+ return b;
+ }
+
+ ///
+ /// Scans the folder again and queues all included scripts in the start order. Returns the batch with its new run,
+ /// the batch unchanged if its run still has Queued or Running scripts, or null if no batch has this id.
+ ///
+ public Batch? Run(int id)
+ {
+ lock (gate)
+ {
+ var i = batches.FindIndex(b => b.Id == id);
+ if (i < 0) return null;
+ var b = batches[i];
+ if (b.Run?.Active == true) return b;
+ var folder = FolderPath(b.File, b.Settings.Folder);
+ var scripts = Merge(b.Scripts, FileScan.Scan(folder, b.Settings.Filters, b.Settings.Recurse));
+ batches[i] = b with { Scripts = scripts, Dirty = b.Dirty || !scripts.SequenceEqual(b.Scripts) };
+ var logParent = b.File is null ? appDataFolder : Path.GetDirectoryName(b.File);
+ // ponytail: a log folder that cannot be made (read-only folder) stops the Run with the IO error; run without logs if users need that.
+ var log = logParent is null ? null : RunLog.Create(logParent, b.Name, time, text => Warn(id, text));
+ var run = runs[id] = shown[id] = new RunEngine(this, id, time.GetUtcNow(), folder ?? "", b.Settings, log);
+ run.Start(scripts.Where(s => s.Include).Select(s => s.Path)); // a missing file fails with "File not found" when it starts
+ return Find(id);
+ }
+ }
+
+ ///
+ /// Cancels one script of the current run. A Queued script changes to Cancelled with 0 attempts. A Running script
+ /// changes to Cancelled when its executor stops. Returns the batch, or null if no batch has this id.
+ ///
+ public Batch? Cancel(int id, string path) => Cancel(id, _ => [path]);
+
+ /// Cancels all Running and Queued scripts of the current run. Null if no batch has this id.
+ public Batch? CancelAll(int id) => Cancel(id, run => run.Active);
+
+ Batch? Cancel(int id, Func> paths)
+ {
+ List stop = [];
+ Batch? batch;
+ lock (gate)
+ {
+ if (runs.GetValueOrDefault(id) is { } run) stop = run.Cancel(paths(run));
+ batch = Find(id);
+ }
+ // Outside the lock: a real executor can block while it tells the server to stop.
+ foreach (var cts in stop)
+ try { cts.Cancel(); } catch (ObjectDisposedException) { } // the script ended first
+ return batch;
+ }
+
+ /// Quit and Ctrl+C: cancels all scripts of all batches. The task ends when the Running scripts stopped and their logs are written.
+ public Task StopAsync()
+ {
+ int[] ids;
+ lock (gate) ids = [.. runs.Keys];
+ foreach (var id in ids) CancelAll(id);
+ lock (gate) return Task.WhenAll(finishing);
+ }
+
+ ///
+ /// Reruns one completed script of the current run. It goes to the bottom of the queue and reads its file again.
+ /// Returns the batch, or null if no batch has this id.
+ ///
+ public Batch? Rerun(int id, string path) => Rerun(id, _ => [path]);
+
+ /// Reruns all Failed and Cancelled scripts of the current run, in the start order. Null if no batch has this id.
+ public Batch? RerunFailed(int id) =>
+ Rerun(id, run => run.FailedOrCancelled);
+
+ Batch? Rerun(int id, Func> paths)
+ {
+ lock (gate)
+ {
+ if (runs.GetValueOrDefault(id) is { } run && Find(id) is { } batch) run.Rerun(paths(run), batch.Settings);
+ return Find(id);
+ }
+ }
+
+ // The run engines share the lock and call these under it.
+ internal Lock Gate => gate;
+ internal TimeProvider Time => time;
+ internal ISqlExecutor Executor => executor;
+ internal HashSet Finishing => finishing;
+ internal string? Password(int id) => passwords.GetValueOrDefault(id);
+ internal void ForgetPassword(int id) => passwords.Remove(id);
+ /// Stores and publishes the state of the run, if it is the current run of the batch.
+ internal void Show(int id, RunEngine run)
+ {
+ if (runs.GetValueOrDefault(id) == run && Find(id) is { } b) Put(b with { Run = run.State });
+ }
+ /// True if the batch shows the messages of this run.
+ internal bool Shows(int id, RunEngine run) => shown.GetValueOrDefault(id) == run;
+
+ /// A log write failed. The page shows the text; the run continues.
+ void Warn(int id, string text)
+ {
+ lock (gate) Publish(new Change("warning", new { id, text }));
+ }
+
+ Batch? Find(int id) => batches.Find(b => b.Id == id);
+
+ /// A relative folder is relative to the batch file. An unsaved batch has an absolute folder.
+ static string? FolderPath(string? batchFile, string? folder) =>
+ string.IsNullOrWhiteSpace(folder) || batchFile is null ? folder : Path.Combine(Path.GetDirectoryName(batchFile)!, folder);
+
+ ///
+ /// Known files keep their place and check box; new files go at the bottom in file-name order, marked New.
+ /// Files not on disk stay, marked Missing, until the next save.
+ ///
+ static IReadOnlyList Merge(IReadOnlyList known, IReadOnlyList found)
+ {
+ var onDisk = found.ToHashSet(StringComparer.Ordinal);
+ var old = known.Select(s => s.Path).ToHashSet(StringComparer.Ordinal);
+ return [.. known.Select(s => !onDisk.Contains(s.Path) ? s with { State = ScriptState.Missing } : s.State == ScriptState.Missing ? s with { State = null } : s),
+ .. found.Where(p => !old.Contains(p)).Select(p => new ScriptEntry(p, Include: true, ScriptState.New))];
+ }
+
+ /// The snapshot and the subscription are taken under one lock, so no change is lost between them.
+ public Subscription Subscribe()
+ {
+ var channel = Channel.CreateUnbounded();
+ lock (gate)
+ {
+ subscribers.Add(channel);
+ List messages = [.. shown.Values.SelectMany(r => r.Kept)];
+ return new Subscription([.. batches], messages, channel.Reader, () => { lock (gate) subscribers.Remove(channel); });
+ }
+ }
+
+ // Call under the lock, so each subscriber sees the changes in order.
+ internal void Publish(Change change)
+ {
+ foreach (var s in subscribers) s.Writer.TryWrite(change);
+ string[] now = [.. batches.Where(b => b.File is not null).Select(b => b.File!)];
+ if (now.SequenceEqual(files)) return;
+ files = now;
+ FilesChanged?.Invoke(now);
+ }
+
+ public sealed class Subscription(IReadOnlyList snapshot, IReadOnlyList messages, ChannelReader changes, Action unsubscribe) : IDisposable
+ {
+ public IReadOnlyList Snapshot => snapshot;
+ /// The kept messages of the runs that the batches show.
+ public IReadOnlyList Messages => messages;
+ public ChannelReader Changes => changes;
+ public void Dispose() => unsubscribe();
+ }
+}
diff --git a/src/SqlRunboard.Core/FakeSqlExecutor.cs b/src/SqlRunboard.Core/FakeSqlExecutor.cs
new file mode 100644
index 0000000..2bdbd17
--- /dev/null
+++ b/src/SqlRunboard.Core/FakeSqlExecutor.cs
@@ -0,0 +1,43 @@
+using System.Collections.Concurrent;
+
+namespace SqlRunboard.Core;
+
+/// What the fake does for one script. The default succeeds at once.
+public sealed record FakeScript
+{
+ public TimeSpan Duration { get; init; }
+ public ScriptStatus Status { get; init; } = ScriptStatus.Succeeded;
+ public long? Rows { get; init; }
+ public string? Error { get; init; }
+ public IReadOnlyList Messages { get; init; } = [];
+ public bool LogonFailed { get; init; }
+}
+
+///
+/// Plays a for each script name, and records each call.
+/// The duration waits on the , so a fake clock controls when a script ends.
+///
+public sealed class FakeSqlExecutor(TimeProvider time) : ISqlExecutor
+{
+ public ConcurrentDictionary Scripts { get; } = new();
+ public ConcurrentQueue Calls { get; } = new();
+ public ConcurrentQueue Logons { get; } = new();
+ /// What Test connection gives. The default connects as the user (or "fake") to the server and database of the target.
+ public Func Logon { get; set; } = c => new SqlLogon(c.Target.Server, c.Target.Database, c.Target.User ?? "fake");
+
+ public async Task ExecuteAsync(SqlExecution execution, Action onMessage, CancellationToken ct)
+ {
+ Calls.Enqueue(execution);
+ var script = Scripts.GetValueOrDefault(execution.ScriptName) ?? new FakeScript();
+ foreach (var m in script.Messages) onMessage(m);
+ await Task.Delay(script.Duration, time, ct);
+ if (script.Error is not null) onMessage(new SqlMessage(SqlMessageKind.Error, script.Error));
+ return new SqlResult(script.Status, script.Rows, script.Error, script.LogonFailed);
+ }
+
+ public Task LogonAsync(SqlConnectionSettings connection, CancellationToken ct)
+ {
+ Logons.Enqueue(connection);
+ return Task.FromResult(Logon(connection));
+ }
+}
diff --git a/src/SqlRunboard.Core/FileScan.cs b/src/SqlRunboard.Core/FileScan.cs
new file mode 100644
index 0000000..634e626
--- /dev/null
+++ b/src/SqlRunboard.Core/FileScan.cs
@@ -0,0 +1,25 @@
+using System.Globalization;
+
+namespace SqlRunboard.Core;
+
+/// Finds the script files of a batch. See docs/SPEC.md "Scripts" and "Script order".
+public static class FileScan
+{
+ /// Natural sort on the relative path: 2_x.sql comes before 10_x.sql.
+ public static readonly IComparer NaturalOrder =
+ StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering);
+
+ /// The files in folder that match one of the ;-separated filters, as paths relative to folder with /, in natural order.
+ public static IReadOnlyList Scan(string? folder, string filters, bool recurse)
+ {
+ if (string.IsNullOrWhiteSpace(folder) || !Directory.Exists(folder)) return [];
+ // Simple match: only * and ? are wildcards, without the Win32 quirks (8.3 names, "*.sql" also matching "x.sqlx").
+ var options = new EnumerationOptions { MatchType = MatchType.Simple, RecurseSubdirectories = recurse, IgnoreInaccessible = true };
+ return filters.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
+ .SelectMany(f => Directory.EnumerateFiles(folder, f, options))
+ .Select(f => Path.GetRelativePath(folder, f).Replace('\\', '/'))
+ .Distinct(StringComparer.Ordinal)
+ .Order(NaturalOrder).ThenBy(p => p, StringComparer.Ordinal)
+ .ToList();
+ }
+}
diff --git a/src/SqlRunboard.Core/Run.cs b/src/SqlRunboard.Core/Run.cs
new file mode 100644
index 0000000..f574e0a
--- /dev/null
+++ b/src/SqlRunboard.Core/Run.cs
@@ -0,0 +1,26 @@
+namespace SqlRunboard.Core;
+
+/// One attempt of one script. End is null while it runs.
+public sealed record Attempt(DateTimeOffset Start, DateTimeOffset? End, ScriptStatus Status, long? Rows, string? Error);
+
+/// One script of a run. Status is the status of the last attempt, or Queued. Error: why a script with 0 attempts did not start.
+public sealed record ScriptRun(string Path, ScriptStatus Status, IReadOnlyList Attempts, string? Error = null);
+
+///
+/// The current run of a batch, as the page sees it. Scripts are in the start order; Queue has the Queued paths in queue order.
+/// Now is the server clock when this state was made, so the page can show the elapsed time of Running scripts.
+/// Ended: the target or the file scan changed after the run. Reruns do nothing until the next Run.
+///
+public sealed record RunState(DateTimeOffset Start, DateTimeOffset Now, IReadOnlyList Scripts, IReadOnlyList Queue, bool Ended = false)
+{
+ public bool Active => Scripts.Any(s => s.Status is ScriptStatus.Queued or ScriptStatus.Running);
+}
+
+/// One message of an attempt, for the drawer. Time is the server clock.
+public sealed record RunMessage(DateTimeOffset Time, SqlMessageKind Kind, string Text);
+
+/// SSE "message": a new message of attempt number Attempt (1-based) of the script Path in the batch Id.
+public sealed record ScriptMessage(int Id, string Path, int Attempt, DateTimeOffset Time, SqlMessageKind Kind, string Text);
+
+/// The kept messages of one attempt: the last . Earlier = the older ones, only in run.log.
+public sealed record AttemptMessages(int Id, string Path, int Attempt, int Earlier, IReadOnlyList Messages);
diff --git a/src/SqlRunboard.Core/RunEngine.cs b/src/SqlRunboard.Core/RunEngine.cs
new file mode 100644
index 0000000..53ea82d
--- /dev/null
+++ b/src/SqlRunboard.Core/RunEngine.cs
@@ -0,0 +1,188 @@
+namespace SqlRunboard.Core;
+
+///
+/// The run engine of one run of a batch: its queue and parallel limit, the executors, the attempts, their messages and the log.
+/// It has no lock of its own. It shares the lock of its , so each subscriber sees all changes in one order:
+/// BatchList calls it under that lock, and the executor callbacks take that lock.
+///
+sealed class RunEngine(BatchList list, int id, DateTimeOffset start, string folder, BatchSettings settings, RunLog? log)
+{
+ readonly OrderedDictionary scripts = new(StringComparer.Ordinal); // the start order
+ readonly List queue = [];
+ readonly HashSet cancelled = new(StringComparer.Ordinal); // Running scripts that you cancelled: not a timeout
+ readonly Dictionary tokens = new(StringComparer.Ordinal); // one per Running script
+ readonly Dictionary<(string Path, int Attempt), (Queue Kept, int Earlier)> messages = [];
+ BatchSettings settings = settings; // a rerun sets the current settings; they cannot change while scripts run
+ int running;
+ List? held; // while Pump starts executors: their first messages, to publish after the run state
+
+ /// The run as the page sees it, at the server clock now.
+ public RunState State => new(start, list.Time.GetUtcNow(), [.. scripts.Values], [.. queue]);
+
+ /// The kept messages of each attempt, for a new subscriber.
+ public IEnumerable Kept => messages.Select(m => new AttemptMessages(id, m.Key.Path, m.Key.Attempt, m.Value.Earlier, [.. m.Value.Kept]));
+
+ /// Run: queues these scripts in this order and starts them.
+ public void Start(IEnumerable paths)
+ {
+ foreach (var path in paths)
+ {
+ scripts[path] = new ScriptRun(path, ScriptStatus.Queued, []);
+ queue.Add(path);
+ }
+ Pump();
+ }
+
+ /// After a restart: the scripts of the run in summary.csv. Nothing starts.
+ public void Restore(IEnumerable runs) { foreach (var s in runs) scripts[s.Path] = s; }
+
+ /// All Queued and Running scripts.
+ public IReadOnlyList Active => [.. queue, .. tokens.Keys];
+
+ /// All Failed and Cancelled scripts, in the start order.
+ public IReadOnlyList FailedOrCancelled => [.. scripts.Values.Where(s => s.Status is ScriptStatus.Failed or ScriptStatus.Cancelled).Select(s => s.Path)];
+
+ ///
+ /// A Queued script changes to Cancelled with 0 attempts. A Running script changes to Cancelled when its executor stops:
+ /// cancel the returned tokens outside the lock, because a real executor can block while it tells the server to stop.
+ ///
+ public List Cancel(IReadOnlyList paths)
+ {
+ List stop = [];
+ foreach (var path in paths)
+ if (queue.Remove(path)) scripts[path] = scripts[path] with { Status = ScriptStatus.Cancelled };
+ else if (tokens.TryGetValue(path, out var cts) && cancelled.Add(path)) stop.Add(cts);
+ Pump();
+ return stop;
+ }
+
+ /// Reruns ignore the check boxes. They add attempts to this run, with the current settings but the target of the run.
+ public void Rerun(IReadOnlyList paths, BatchSettings current)
+ {
+ settings = current with { Target = settings.Target }; // reruns use the current parallel limit, stop setting and timeout
+ foreach (var path in paths)
+ if (scripts.TryGetValue(path, out var s) && s.Status is ScriptStatus.Succeeded or ScriptStatus.Failed or ScriptStatus.Cancelled)
+ {
+ scripts[path] = s with { Status = ScriptStatus.Queued, Error = null };
+ queue.Add(path);
+ }
+ Pump();
+ }
+
+ /// Moves a Queued script in the queue of this run.
+ public void Move(string path, MoveTo to)
+ {
+ if (BatchList.MoveIn(queue, p => p == path, to)) Pump();
+ }
+
+ void Add(string path, Attempt a) { var s = scripts[path]; scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts, a] }; }
+ void SetLast(string path, Attempt a) { var s = scripts[path]; scripts[path] = s with { Status = a.Status, Attempts = [.. s.Attempts.SkipLast(1), a] }; }
+
+ /// "Stop the queue at the first failure": no new scripts start. The Running scripts continue.
+ void Failed()
+ {
+ if (!settings.StopOnFirstFailure) return;
+ foreach (var path in queue) scripts[path] = scripts[path] with { Status = ScriptStatus.Cancelled, Error = "Not started: the queue stopped" };
+ queue.Clear();
+ }
+
+ // Call under the lock. Starts Queued scripts up to the parallel limit, then publishes the run.
+ // The executors start before the publish, so a Running script that a subscriber sees has started.
+ // Their messages wait until after the publish, so the page sees an attempt before its first message.
+ void Pump()
+ {
+ var time = list.Time;
+ List starts = [];
+ while (running < settings.ParallelLimit && queue.Count > 0)
+ {
+ var path = queue[0];
+ queue.RemoveAt(0);
+ var begin = time.GetUtcNow();
+ IReadOnlyList parts;
+ // Each script reads its file when it starts, so you can fix a Queued script.
+ // ponytail: the read runs under the lock; move it out if slow network folders block the page.
+ try { parts = SqlScript.Read(Path.Combine(folder, path)); }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ var error = e is FileNotFoundException or DirectoryNotFoundException ? "File not found" : e.Message;
+ Add(path, new Attempt(begin, begin, ScriptStatus.Failed, null, error));
+ log?.Append($"{Attempt(path)}: Failed: {error}");
+ var n = scripts[path].Attempts.Count;
+ starts.Add(() => AddMessage(path, n, new(SqlMessageKind.Error, error)));
+ Failed();
+ continue;
+ }
+ running++;
+ Add(path, new Attempt(begin, null, ScriptStatus.Running, null, null));
+ var (attempt, number) = (Attempt(path), scripts[path].Attempts.Count);
+ log?.Append($"{attempt}: Started");
+ var execution = new SqlExecution(path, parts, new SqlConnectionSettings(settings.Target, list.Password(id)));
+ // The continuation runs on the thread pool, never inline here under the lock.
+ var cts = tokens[path] = settings.TimeoutSeconds is int s ? new CancellationTokenSource(TimeSpan.FromSeconds(s), time) : new CancellationTokenSource();
+ void OnMessage(SqlMessage m)
+ {
+ log?.Append($"{attempt}: {(m.Kind == SqlMessageKind.Error ? "Error: " : "")}{m.Text}"); // outside the lock: a file write
+ lock (list.Gate) AddMessage(path, number, m);
+ }
+ starts.Add(() =>
+ {
+ Task task;
+ try { task = list.Executor.ExecuteAsync(execution, OnMessage, cts.Token); }
+ catch (Exception e) { task = Task.FromException(e); } // a throw before the first await fails the attempt too
+ Task? done = null;
+ // The continuation takes the lock, so it cannot remove 'done' before this method adds it.
+ done = task.ContinueWith(t =>
+ {
+ try { Finish(path, begin, t); }
+ finally { lock (list.Gate) list.Finishing.Remove(done!); }
+ }, TaskScheduler.Default);
+ list.Finishing.Add(done);
+ });
+ }
+ var outer = held;
+ held = [];
+ foreach (var a in starts) a();
+ var later = held;
+ held = outer;
+ log?.Update(scripts.Values, settings.Target); // also for a run that is no longer current: its late ends go to its log
+ list.Show(id, this);
+ foreach (var m in later) list.Publish(m);
+ }
+
+ // Call under the lock. Keeps the last MaxMessages of the attempt, and sends the message to the page if the batch shows this run.
+ void AddMessage(string path, int attempt, SqlMessage m)
+ {
+ var (kept, earlier) = messages.GetValueOrDefault((path, attempt), ([], 0));
+ var message = new RunMessage(list.Time.GetUtcNow(), m.Kind, m.Text);
+ kept.Enqueue(message);
+ if (kept.Count > BatchList.MaxMessages) { kept.Dequeue(); earlier++; }
+ messages[(path, attempt)] = (kept, earlier);
+ if (!list.Shows(id, this)) return;
+ var change = new Change("message", new ScriptMessage(id, path, attempt, message.Time, m.Kind, m.Text));
+ if (held is not null) held.Add(change); else list.Publish(change);
+ }
+
+ void Finish(string path, DateTimeOffset begin, Task t)
+ {
+ lock (list.Gate)
+ {
+ var end = list.Time.GetUtcNow();
+ if (tokens.Remove(path, out var cts)) cts.Dispose();
+ var stopped = cancelled.Remove(path);
+ SetLast(path, t.IsCompletedSuccessfully ? new Attempt(begin, end, t.Result.Status, t.Result.Rows, t.Result.Error)
+ : t.IsCanceled && stopped ? new Attempt(begin, end, ScriptStatus.Cancelled, null, null)
+ : t.IsCanceled ? new Attempt(begin, end, ScriptStatus.Failed, null, $"Timeout after {settings.TimeoutSeconds} s")
+ : new Attempt(begin, end, ScriptStatus.Failed, null, t.Exception!.GetBaseException().Message));
+ var a = scripts[path].Attempts[^1];
+ if (!t.IsCompletedSuccessfully && a.Error is { } why) AddMessage(path, scripts[path].Attempts.Count, new(SqlMessageKind.Error, why)); // the executor did not send it
+ log?.Append($"{Attempt(path)}: {a.Status}" + (a.Rows is { } rows ? $", {rows} rows" : "") + (a.Error is { } err ? $": {err}" : ""));
+ running--;
+ if (t.IsCompletedSuccessfully && t.Result.LogonFailed) list.ForgetPassword(id); // the page asks again at the next run
+ if (scripts[path].Status == ScriptStatus.Failed) Failed();
+ Pump();
+ }
+ }
+
+ /// "1.sql #2": the script and the number of its last attempt, for run.log.
+ string Attempt(string path) => $"{path} #{scripts[path].Attempts.Count}";
+}
diff --git a/src/SqlRunboard.Core/RunLog.cs b/src/SqlRunboard.Core/RunLog.cs
new file mode 100644
index 0000000..abd85ac
--- /dev/null
+++ b/src/SqlRunboard.Core/RunLog.cs
@@ -0,0 +1,178 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace SqlRunboard.Core;
+
+///
+/// The log folder of one run: run.log (all messages of all attempts, a summary at each end of the run) and summary.csv
+/// (written again at each status change, to a temporary file and then with a rename). Thread-safe.
+/// A write that fails does not stop the run: it calls warn and is tried again later.
+///
+sealed class RunLog
+{
+ public const string LogFile = "run.log", SummaryFile = "summary.csv";
+ static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); // Excel reads UTF-8 only with a BOM
+
+ readonly string folder;
+ readonly TimeProvider time;
+ readonly Action warn;
+ readonly Lock gate = new();
+ bool active = true, warned;
+
+ RunLog(string folder, TimeProvider time, Action warn) => (this.folder, this.time, this.warn) = (folder, time, warn);
+
+ /// Makes the folder logs/<batch>_<yyyyMMdd-HHmmss> under parent. If the name exists, it adds -2, -3 and so on.
+ public static RunLog Create(string parent, string batchName, TimeProvider time, Action warn)
+ {
+ var name = Path.Combine(parent, "logs", $"{batchName}_{Local(time.GetUtcNow(), time):yyyyMMdd-HHmmss}");
+ var folder = name;
+ for (var n = 2; Directory.Exists(folder); n++) folder = $"{name}-{n}";
+ Directory.CreateDirectory(folder);
+ return new RunLog(folder, time, warn);
+ }
+
+ /// The log folder of an earlier run (after a restart): reruns add to it. Its run is not active.
+ public static RunLog Open(string folder, TimeProvider time, Action warn) => new(folder, time, warn) { active = false };
+
+ /// The newest log folder of the batch under parent/logs that has a summary.csv, or null.
+ public static string? Newest(string parent, string batchName)
+ {
+ var logs = Path.Combine(parent, "logs");
+ if (!Directory.Exists(logs)) return null;
+ var name = new Regex($@"^{Regex.Escape(batchName)}_\d{{8}}-\d{{6}}(-\d+)?$");
+ // ponytail: the newest by folder name (local start time); a run in the repeated hour at a DST change can sort wrong.
+ return Directory.EnumerateDirectories(logs).Where(d => name.IsMatch(Path.GetFileName(d)) && File.Exists(Path.Combine(d, SummaryFile)))
+ .OrderBy(d => Path.GetFileName(d), FileScan.NaturalOrder).LastOrDefault();
+ }
+
+ ///
+ /// Reads summary.csv back: the scripts in the start order, and the server and database of the run.
+ /// A Queued or Running row changes to Cancelled with "The app stopped". Throws IOException or FormatException.
+ ///
+ public static (List Scripts, string Server, string Database) ReadSummary(string folder)
+ {
+ var rows = ParseCsv(File.ReadAllText(Path.Combine(folder, SummaryFile))).Skip(1).Where(r => r.Count >= 10).ToList();
+ static DateTimeOffset? Time(string v) => v == "" ? null : DateTimeOffset.Parse(v, CultureInfo.InvariantCulture);
+ // A Running attempt ends at the last time that the file knows: its last status change.
+ var last = rows.SelectMany(r => new[] { Time(r[3]), Time(r[4]) }).Max();
+ var scripts = new OrderedDictionary(StringComparer.Ordinal);
+ foreach (var r in rows)
+ {
+ var status = Enum.Parse(r[2]);
+ var stopped = status is ScriptStatus.Queued or ScriptStatus.Running;
+ var error = stopped ? "The app stopped" : r[7] == "" ? null : r[7];
+ if (stopped) status = ScriptStatus.Cancelled;
+ var s = scripts.GetValueOrDefault(r[0]) ?? new ScriptRun(r[0], status, []);
+ scripts[r[0]] = r[1] == "0" ? s with { Status = status, Error = error }
+ : s with { Status = status, Attempts = [.. s.Attempts, new Attempt(Time(r[3])!.Value, Time(r[4]) ?? last, status, r[6] == "" ? null : long.Parse(r[6], CultureInfo.InvariantCulture), error)] };
+ }
+ return ([.. scripts.Values], rows.FirstOrDefault()?[8] ?? "", rows.FirstOrDefault()?[9] ?? "");
+ }
+
+ /// RFC 4180: quoted fields can have commas, "" and line breaks.
+ static IEnumerable> ParseCsv(string text)
+ {
+ List row = [];
+ var field = new StringBuilder();
+ bool quoted = false, any = false;
+ for (var i = 0; i < text.Length; i++)
+ {
+ var c = text[i];
+ any = true;
+ if (quoted)
+ {
+ if (c != '"') field.Append(c);
+ else if (i + 1 < text.Length && text[i + 1] == '"') { field.Append('"'); i++; }
+ else quoted = false;
+ }
+ else if (c == '"') quoted = true;
+ else if (c == ',') { row.Add(field.ToString()); field.Clear(); }
+ else if (c == '\n')
+ {
+ row.Add(field.ToString().TrimEnd('\r')); field.Clear();
+ yield return row;
+ (row, any) = ([], false);
+ }
+ else field.Append(c);
+ }
+ if (any) { row.Add(field.ToString()); yield return row; }
+ }
+
+ /// Adds one line to run.log, with the local time.
+ public void Append(string text)
+ {
+ string? error = null;
+ lock (gate)
+ {
+ // ponytail: one open and close for each line; buffer it if many messages make the run slow.
+ try { File.AppendAllText(Path.Combine(folder, LogFile), $"{Format(time.GetUtcNow())} {text}{Environment.NewLine}"); }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException) { error = e.Message; }
+ }
+ // Outside the lock: warn takes the BatchList lock, and a message thread must not hold this lock then.
+ if (error is not null) warn($"Warning: cannot write {LogFile}: {error}");
+ }
+
+ /// Call at each status change. Writes summary.csv again, and the summary in run.log when the run ends.
+ public void Update(IReadOnlyCollection scripts, BatchTarget target)
+ {
+ lock (gate)
+ {
+ WriteSummary(scripts, target);
+ var now = scripts.Any(s => s.Status is ScriptStatus.Queued or ScriptStatus.Running);
+ if (active && !now) AppendSummary(scripts);
+ active = now;
+ }
+ }
+
+ void WriteSummary(IReadOnlyCollection scripts, BatchTarget target)
+ {
+ var csv = new StringBuilder("script,attempt,status,start,end,duration,rows,error,server,database\r\n");
+ void Row(string path, int n, ScriptStatus status, Attempt? a, string? error) =>
+ csv.AppendJoin(',', [Csv(path), n.ToString(CultureInfo.InvariantCulture), status.ToString(),
+ a is null ? "" : Format(a.Start), a?.End is { } end ? Format(end) : "",
+ a?.End is { } e ? (e - a.Start).TotalSeconds.ToString("F3", CultureInfo.InvariantCulture) : "",
+ a?.Rows?.ToString(CultureInfo.InvariantCulture) ?? "", Csv(error), Csv(target.Server), Csv(target.Database)]).Append("\r\n");
+ foreach (var s in scripts)
+ {
+ for (var i = 0; i < s.Attempts.Count; i++) Row(s.Path, i + 1, s.Attempts[i].Status, s.Attempts[i], s.Attempts[i].Error);
+ // Work that did not start gets an attempt 0 row: a script that never started, or one that is Queued for a rerun
+ // (or was then cancelled). ReadSummary takes the status of the script from it.
+ if (s.Attempts.Count == 0 || s.Status != s.Attempts[^1].Status || s.Error is not null) Row(s.Path, 0, s.Status, null, s.Error);
+ }
+
+ var file = Path.Combine(folder, SummaryFile);
+ try
+ {
+ File.WriteAllText(file + ".tmp", csv.ToString(), Utf8Bom);
+ File.Move(file + ".tmp", file, overwrite: true);
+ warned = false;
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ if (warned) return; // one warning until a write works again
+ warned = true;
+ var text = $"Warning: cannot write {SummaryFile}: {e.Message} The run continues. The app tries again at the next status change.";
+ Append(text);
+ warn(text);
+ }
+ }
+
+ void AppendSummary(IReadOnlyCollection scripts)
+ {
+ int Count(ScriptStatus s) => scripts.Count(x => x.Status == s);
+ Append($"Run ended: {scripts.Count} scripts, {Count(ScriptStatus.Succeeded)} succeeded, {Count(ScriptStatus.Failed)} failed, {Count(ScriptStatus.Cancelled)} cancelled.");
+ foreach (var s in scripts)
+ {
+ var last = s.Attempts.LastOrDefault();
+ Append($" {s.Path}: {s.Status}, attempts: {s.Attempts.Count}" + (last?.Rows is { } r ? $", rows: {r}" : "") + ((last?.Error ?? s.Error) is { } err ? $", error: {err}" : ""));
+ }
+ }
+
+ static string Csv(string? v) => v is null ? "" : v.IndexOfAny([',', '"', '\r', '\n']) >= 0 ? $"\"{v.Replace("\"", "\"\"")}\"" : v;
+
+ static DateTimeOffset Local(DateTimeOffset t, TimeProvider time) => TimeZoneInfo.ConvertTime(t, time.LocalTimeZone);
+
+ /// Local time with the UTC offset, for example 2026-10-09T14:03:12+02:00.
+ string Format(DateTimeOffset t) => Local(t, time).ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);
+}
diff --git a/src/SqlRunboard.Core/SqlExecutor.cs b/src/SqlRunboard.Core/SqlExecutor.cs
new file mode 100644
index 0000000..97242ef
--- /dev/null
+++ b/src/SqlRunboard.Core/SqlExecutor.cs
@@ -0,0 +1,31 @@
+namespace SqlRunboard.Core;
+
+public enum ScriptStatus { Queued, Running, Succeeded, Failed, Cancelled }
+
+public enum SqlMessageKind { Info, RowCount, Error }
+
+public sealed record SqlMessage(SqlMessageKind Kind, string Text);
+
+/// The target of the batch plus the password from the page. The page never gets this.
+public sealed record SqlConnectionSettings(BatchTarget Target, string? Password);
+
+///
+/// One attempt of one script: the GO parts to send on one new connection, each part Repeat times, in order.
+/// The executor stops at the first failed part, and shows its error line as "line N (part M)": N = part.Line + server line - 1, M = 1-based index.
+///
+public sealed record SqlExecution(string ScriptName, IReadOnlyList Parts, SqlConnectionSettings Connection);
+
+/// Status is Succeeded or Failed. A cancel throws OperationCanceledException. LogonFailed: the server rejected the logon (wrong password).
+public sealed record SqlResult(ScriptStatus Status, long? Rows, string? Error, bool LogonFailed = false);
+
+/// Test connection: @@SERVERNAME, the database and the login, or the Error. LogonFailed: ask for the password again.
+public sealed record SqlLogon(string? Server, string? Database, string? Login, string? Error = null, bool LogonFailed = false);
+
+/// The seam to SQL Server: ; tests use .
+public interface ISqlExecutor
+{
+ Task ExecuteAsync(SqlExecution execution, Action onMessage, CancellationToken ct);
+
+ /// Opens a connection to the target and reads who and where it is. It does not throw for a logon or network error.
+ Task LogonAsync(SqlConnectionSettings connection, CancellationToken ct);
+}
diff --git a/src/SqlRunboard.Core/SqlRunboard.Core.csproj b/src/SqlRunboard.Core/SqlRunboard.Core.csproj
new file mode 100644
index 0000000..7ad41f4
--- /dev/null
+++ b/src/SqlRunboard.Core/SqlRunboard.Core.csproj
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/SqlRunboard.Core/SqlScript.cs b/src/SqlRunboard.Core/SqlScript.cs
new file mode 100644
index 0000000..6192c45
--- /dev/null
+++ b/src/SqlRunboard.Core/SqlScript.cs
@@ -0,0 +1,71 @@
+using System.Text.RegularExpressions;
+
+namespace SqlRunboard.Core;
+
+/// One GO part: the text to send, how many times to run it (GO n), and its first line in the file (1-based).
+public sealed record ScriptPart(string Text, int Repeat, int Line);
+
+/// The script reader and GO splitter.
+public static partial class SqlScript
+{
+ /// Reads the file (a UTF-8 or UTF-16 BOM selects the encoding, else UTF-8) and splits it.
+ public static IReadOnlyList Read(string path) => Split(File.ReadAllText(path));
+
+ ///
+ /// Splits on lines that hold only GO or GO n (any case, spaces and a -- comment after it), as SSMS does.
+ /// GO inside a /* */ comment (nested too) or a '', "" or [] literal that continues across lines does not split.
+ ///
+ public static IReadOnlyList Split(string text)
+ {
+ var parts = new List();
+ int start = 0, startLine = 1, line = 1, depth = 0; // depth: nested /* */ comments open at this point
+ var close = '\0'; // the quote that ends the open literal, or \0
+ for (var pos = 0; pos <= text.Length; line++)
+ {
+ var end = text.IndexOf('\n', pos);
+ if (end < 0) end = text.Length;
+ var go = depth == 0 && close == '\0' ? GoLine().Match(text, pos, end - pos) : Match.Empty;
+ var repeat = 1;
+ if (go.Success && (!go.Groups[1].Success || int.TryParse(go.Groups[1].Value, out repeat) && repeat > 0))
+ {
+ Add(text[start..pos], repeat);
+ start = Math.Min(end + 1, text.Length);
+ startLine = line + 1;
+ }
+ else Scan(text, pos, end, ref depth, ref close);
+ pos = end + 1;
+ }
+ Add(text[start..], 1);
+ return parts;
+
+ void Add(string part, int repeat)
+ {
+ part = part.TrimEnd();
+ if (part.Trim().Length > 0) parts.Add(new ScriptPart(part, repeat, startLine));
+ }
+ }
+
+ // Moves the comment and literal state over text[pos..end] (one line).
+ static void Scan(string text, int pos, int end, ref int depth, ref char close)
+ {
+ for (var i = pos; i < end; i++)
+ {
+ var c = text[i];
+ var n = i + 1 < end ? text[i + 1] : '\0';
+ if (close != '\0')
+ {
+ if (c != close) continue;
+ if (n == close) i++; // '' or ]] or "" is an escaped quote
+ else close = '\0';
+ }
+ else if (c == '/' && n == '*') { depth++; i++; }
+ else if (depth > 0) { if (c == '*' && n == '/') { depth--; i++; } }
+ else if (c == '-' && n == '-') return;
+ else if (c is '\'' or '"') close = c;
+ else if (c == '[') close = ']';
+ }
+ }
+
+ [GeneratedRegex(@"^\s*GO(?:\s+(\d+))?\s*(?:--.*)?$", RegexOptions.IgnoreCase)]
+ private static partial Regex GoLine();
+}
diff --git a/src/SqlRunboard.Core/SqlServerExecutor.cs b/src/SqlRunboard.Core/SqlServerExecutor.cs
new file mode 100644
index 0000000..4e9b684
--- /dev/null
+++ b/src/SqlRunboard.Core/SqlServerExecutor.cs
@@ -0,0 +1,103 @@
+using Microsoft.Data.SqlClient;
+
+namespace SqlRunboard.Core;
+
+/// The real : Microsoft.Data.SqlClient. See docs/SPEC.md "Execution".
+public sealed class SqlServerExecutor : ISqlExecutor
+{
+ ///
+ /// The additional text first, then the target settings over it. Pooling is always off, and the application name is
+ /// "SQLRunboard" plus the script name (null for Test connection).
+ ///
+ public static string ConnectionString(SqlConnectionSettings connection, string? scriptName)
+ {
+ var t = connection.Target;
+ var b = new SqlConnectionStringBuilder(t.ExtraConnectionString)
+ {
+ Encrypt = t.Encrypt switch { Encrypt.Optional => SqlConnectionEncryptOption.Optional, Encrypt.Strict => SqlConnectionEncryptOption.Strict, _ => SqlConnectionEncryptOption.Mandatory },
+ TrustServerCertificate = t.TrustServerCertificate,
+ Pooling = false,
+ ApplicationName = scriptName is null ? "SQLRunboard" : ("SQLRunboard " + scriptName)[..Math.Min(128, 12 + scriptName.Length)],
+ };
+ if (t.Server is not null) b.DataSource = t.Server;
+ if (t.Database is not null) b.InitialCatalog = t.Database;
+ switch (t.Auth)
+ {
+ case Auth.Integrated: b.IntegratedSecurity = true; break;
+ case Auth.SqlServer: b.UserID = t.User ?? ""; b.Password = connection.Password ?? ""; break;
+ default: throw new NotSupportedException("Microsoft Entra ID logon comes in a later version. Use Integrated or SQL Server.");
+ }
+ return b.ConnectionString;
+ }
+
+ ///
+ /// One new connection for the script. The parts run in order, each Repeat times, with no transaction from the app.
+ /// Result sets are discarded; each statement's row count comes as a message. The first failed part stops the script.
+ /// The engine owns the timeout: a cancel of the token always ends as a cancelled task, never as a SQL error.
+ ///
+ public async Task ExecuteAsync(SqlExecution execution, Action onMessage, CancellationToken ct)
+ {
+ try
+ {
+ await using var connection = new SqlConnection(ConnectionString(execution.Connection, execution.ScriptName));
+ connection.InfoMessage += (_, e) => { foreach (SqlError info in e.Errors) onMessage(new(SqlMessageKind.Info, info.Message)); };
+ try { await connection.OpenAsync(ct); }
+ catch (SqlException e) when (!ct.IsCancellationRequested)
+ {
+ onMessage(new(SqlMessageKind.Error, e.Message));
+ return new(ScriptStatus.Failed, null, e.Message, IsLogonFailure(e));
+ }
+ long? rows = null;
+ for (var i = 0; i < execution.Parts.Count; i++)
+ {
+ var part = execution.Parts[i];
+ for (var n = 0; n < part.Repeat; n++)
+ {
+ await using var command = new SqlCommand(part.Text, connection) { CommandTimeout = 0 };
+ command.StatementCompleted += (_, e) =>
+ {
+ rows = (rows ?? 0) + e.RecordCount;
+ onMessage(new(SqlMessageKind.RowCount, e.RecordCount == 1 ? "1 row affected" : $"{e.RecordCount} rows affected"));
+ };
+ try { await command.ExecuteNonQueryAsync(ct); }
+ catch (SqlException e) when (!ct.IsCancellationRequested)
+ {
+ string? first = null;
+ foreach (SqlError error in e.Errors)
+ {
+ if (error.Class <= 10) { onMessage(new(SqlMessageKind.Info, error.Message)); continue; }
+ // The server counts lines in the part; the file line adds the line where the part starts.
+ var text = $"line {part.Line + Math.Max(error.LineNumber, 1) - 1} (part {i + 1}): {error.Message}";
+ first ??= text;
+ onMessage(new(SqlMessageKind.Error, text));
+ }
+ return new(ScriptStatus.Failed, rows, first ?? e.Message);
+ }
+ }
+ }
+ return new(ScriptStatus.Succeeded, rows, null);
+ }
+ catch (Exception e) when (e is not OperationCanceledException && ct.IsCancellationRequested)
+ {
+ throw new OperationCanceledException(ct); // SqlClient reports a cancel as a SqlException
+ }
+ }
+
+ public async Task LogonAsync(SqlConnectionSettings connection, CancellationToken ct)
+ {
+ try
+ {
+ await using var c = new SqlConnection(ConnectionString(connection, scriptName: null));
+ await c.OpenAsync(ct);
+ await using var command = new SqlCommand("SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()", c);
+ await using var r = await command.ExecuteReaderAsync(ct);
+ await r.ReadAsync(ct);
+ return new(r.IsDBNull(0) ? null : r.GetString(0), r.GetString(1), r.GetString(2));
+ }
+ catch (SqlException e) { return new(null, null, null, e.Message, IsLogonFailure(e)); }
+ catch (Exception e) when (e is not OperationCanceledException) { return new(null, null, null, e.Message); }
+ }
+
+ /// 18456: "Login failed for user". Another password can help; a network or database error cannot.
+ static bool IsLogonFailure(SqlException e) => e.Number == 18456;
+}
diff --git a/src/SqlRunboard.Gui/GuiHost.cs b/src/SqlRunboard.Gui/GuiHost.cs
new file mode 100644
index 0000000..c3c6e94
--- /dev/null
+++ b/src/SqlRunboard.Gui/GuiHost.cs
@@ -0,0 +1,320 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.ServerSentEvents;
+using System.Runtime.CompilerServices;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SqlRunboard.Core;
+
+namespace SqlRunboard.Gui;
+
+/// Settings, lock file and logs of unsaved batches. Created if missing.
+/// Shows a folder in the OS file manager. Null: the OS default (tests pass a recorder).
+public sealed record GuiHostOptions(string AppDataFolder, ISqlExecutor Executor, TimeProvider Time, Action? OpenFolder = null);
+
+public sealed record IncludeRequest(string Path, bool Include);
+/// A file filter such as *.sqlrunboard.json: the answer then also lists those files. Null: folders only.
+public sealed record FolderRequest(string? Path, string? Files = null);
+public sealed record PathRequest(string Path);
+/// Null: Save to the current file. Otherwise Save as.
+public sealed record SaveRequest(string? Path, bool Overwrite);
+public sealed record LogonRequest(string? Password);
+public sealed record ScriptRequest(string Path);
+public sealed record MoveRequest(string Path, MoveTo To);
+public sealed record MoveBatchRequest(MoveTo To);
+
+/// The local web server: Kestrel on 127.0.0.1, a random port, a random token. See docs/SPEC.md "GUI host".
+public sealed class GuiHost : IAsyncDisposable
+{
+ public const string CookieName = "sqlrunboard";
+ /// In the app data folder. JSON {"url","token"}. Shared by sqlrunboard and sqlrunboard-desktop.
+ public const string LockFileName = "sqlrunboard.lock";
+
+ readonly WebApplication app;
+ readonly string lockPath;
+
+ GuiHost(WebApplication app, Uri baseUrl, string token, string lockPath) { this.app = app; BaseUrl = baseUrl; Token = token; this.lockPath = lockPath; }
+
+ public Uri BaseUrl { get; }
+ public string Token { get; }
+ /// The URL to open. The first request changes the token into a cookie.
+ public Uri StartUrl => new(BaseUrl, "/?token=" + Token);
+
+ public static async Task StartAsync(GuiHostOptions options)
+ {
+ Directory.CreateDirectory(options.AppDataFolder);
+ var token = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
+
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseKestrel(k => k.Listen(IPAddress.Loopback, 0));
+ builder.Configuration["AllowedHosts"] = "127.0.0.1;localhost"; // the built-in host filtering answers 400 to other hosts
+ builder.Logging.SetMinimumLevel(LogLevel.Warning);
+ builder.Services.AddSingleton(options);
+ builder.Services.AddSingleton(options.Executor);
+ builder.Services.AddSingleton(options.Time);
+ builder.Services.AddSingleton(new BatchList(options.Executor, options.Time, options.AppDataFolder));
+ builder.Services.AddSingleton();
+ builder.Services.ConfigureHttpJsonOptions(j => j.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
+
+ var app = builder.Build();
+ RestoreBatches(app.Services.GetRequiredService(), app.Services.GetRequiredService(), app.Logger);
+ app.Use(RejectForeignRequests);
+ app.Use(RequireToken(token));
+ var files = new EmbeddedFileProvider(typeof(GuiHost).Assembly, "SqlRunboard.Gui.wwwroot");
+ app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files });
+ app.UseStaticFiles(new StaticFileOptions { FileProvider = files });
+
+ // Server to page: SSE. Page to server: POST with a JSON body, one route per command.
+ app.MapGet("/events", (BatchList list, AppSettings settings, IHostApplicationLifetime lifetime, CancellationToken ct) =>
+ TypedResults.ServerSentEvents(Events(list, settings, lifetime.ApplicationStopping, ct)));
+ app.MapPost("/api/app-settings", (JsonNode? changes, AppSettings settings) =>
+ {
+ if (changes is not JsonObject o) return Results.BadRequest();
+ settings.Merge(o);
+ return Results.NoContent();
+ });
+ app.MapPost("/api/batches", (BatchList list) => list.NewBatch());
+ app.MapPost("/api/batches/{id:int}/settings", (int id, BatchSettings settings, BatchList list) =>
+ !settings.IsValid ? Results.BadRequest()
+ : Found(list.UpdateSettings(id, settings)));
+ app.MapPost("/api/batches/{id:int}/include", (int id, IncludeRequest r, BatchList list) =>
+ Found(list.SetInclude(id, r.Path, r.Include)));
+ app.MapPost("/api/batches/{id:int}/logon", async (int id, LogonRequest r, BatchList list, CancellationToken ct) =>
+ Found(await list.LogonAsync(id, r.Password, ct)));
+ app.MapPost("/api/batches/{id:int}/run", (int id, BatchList list) =>
+ Found(list.Run(id)));
+ app.MapPost("/api/batches/{id:int}/cancel", (int id, ScriptRequest r, BatchList list) =>
+ Found(list.Cancel(id, r.Path)));
+ app.MapPost("/api/batches/{id:int}/cancel-all", (int id, BatchList list) =>
+ Found(list.CancelAll(id)));
+ app.MapPost("/api/batches/{id:int}/move", (int id, MoveRequest r, BatchList list) =>
+ Found(list.Move(id, r.Path, r.To)));
+ app.MapPost("/api/batches/{id:int}/sort", (int id, BatchList list) =>
+ Found(list.SortByFileName(id)));
+ app.MapPost("/api/batches/{id:int}/move-in-list", (int id, MoveBatchRequest r, BatchList list) =>
+ list.MoveBatch(id, r.To) ? Results.Ok(new { }) : Results.NotFound());
+ app.MapPost("/api/batches/{id:int}/rerun", (int id, ScriptRequest r, BatchList list) =>
+ Found(list.Rerun(id, r.Path)));
+ app.MapPost("/api/batches/{id:int}/rerun-failed", (int id, BatchList list) =>
+ Found(list.RerunFailed(id)));
+ app.MapPost("/api/batches/open", (PathRequest r, BatchList list) => FileResult(() => list.Open(r.Path)));
+ app.MapPost("/api/batches/{id:int}/save", (int id, SaveRequest r, BatchList list) => FileResult(() => list.Save(id, r.Path, r.Overwrite)));
+ app.MapPost("/api/batches/{id:int}/reload", (int id, BatchList list) => FileResult(() => list.Reload(id)));
+ app.MapPost("/api/batches/{id:int}/remove", (int id, BatchList list) => list.Remove(id) ? Results.Ok(new { }) : Results.NotFound());
+ // Quit: the page asked to save and confirmed. The answer comes when the scripts stopped and the logs are written.
+ app.MapPost("/api/quit", async (BatchList list, IHostApplicationLifetime lifetime) =>
+ {
+ await StopRunsAsync(list);
+ lifetime.StopApplication(); // the ApplicationStopping handler below then has no runs to stop
+ return Results.Ok(new { });
+ });
+ app.MapPost("/api/app-data/open", (GuiHostOptions o) =>
+ {
+ // UseShellExecute on a folder opens the file manager: Explorer, Finder or xdg-open.
+ (o.OpenFolder ?? (f => Process.Start(new ProcessStartInfo(f) { UseShellExecute = true })))(o.AppDataFolder);
+ return Results.Ok(new { });
+ });
+ // The browser has no folder or file picker that gives a path, so the page browses the folders here.
+ app.MapPost("/api/folders", (FolderRequest r) =>
+ {
+ var path = string.IsNullOrWhiteSpace(r.Path) ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) : Path.GetFullPath(r.Path);
+ if (File.Exists(path)) path = Path.GetDirectoryName(path)!; // a batch file: start in its folder
+ if (!Directory.Exists(path)) return Results.NotFound();
+ var options = new EnumerationOptions { IgnoreInaccessible = true, MatchType = MatchType.Simple };
+ var folders = Directory.EnumerateDirectories(path, "*", options).Order(FileScan.NaturalOrder).Select(d => new { name = Path.GetFileName(d), path = d });
+ var files = r.Files is null ? [] : Directory.EnumerateFiles(path, r.Files, options).Order(FileScan.NaturalOrder).Select(f => new { name = Path.GetFileName(f), path = f });
+ return Results.Ok(new { path, parent = Path.GetDirectoryName(path), folders, files });
+ });
+
+ var lockPath = Path.Combine(options.AppDataFolder, LockFileName);
+ // Quit, Ctrl+C (ConsoleLifetime), SIGTERM and DisposeAsync all come here: cancel all scripts, write the logs, remove the lock file.
+ // The handler must be synchronous; Quit stops the runs before, so only Ctrl+C, SIGTERM and DisposeAsync wait here.
+ app.Lifetime.ApplicationStopping.Register(() =>
+ {
+ StopRunsAsync(app.Services.GetRequiredService()).GetAwaiter().GetResult();
+ File.Delete(lockPath);
+ });
+
+ await app.StartAsync();
+ var host = new GuiHost(app, new Uri(app.Urls.First()), token, lockPath);
+ host.WriteLock();
+ return host;
+ }
+
+ /// Cancels all scripts and waits until their logs are written.
+ static async Task StopRunsAsync(BatchList list)
+ {
+ // ponytail: a script that does not stop in 10 s keeps no final log line; raise the limit if a real cancel takes longer.
+ try { await list.StopAsync().WaitAsync(TimeSpan.FromSeconds(10)); }
+ catch (TimeoutException) { }
+ }
+
+ ///
+ /// The side list comes back from the app settings, and each change of its batch files goes there.
+ /// A write error does not fail the batch command that changed the list: the next change of the list writes it again.
+ ///
+ static void RestoreBatches(BatchList list, AppSettings settings, ILogger log)
+ {
+ const string Name = "batchFiles";
+ var files = settings.Snapshot()[Name] as JsonArray ?? [];
+ list.Restore(files.Where(f => f?.GetValueKind() == JsonValueKind.String).Select(f => f!.GetValue()));
+ list.FilesChanged = f =>
+ {
+ try { settings.Merge(new JsonObject { [Name] = new JsonArray([.. f.Select(x => (JsonNode)x)]) }); }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException) { log.LogWarning("Cannot write {File}: {Error}", AppSettings.FileName, e.Message); }
+ };
+ }
+
+ /// 200 with the result, or 404 when no batch has the id.
+ static IResult Found(object? result) => result is null ? Results.NotFound() : Results.Ok(result);
+
+ /// A batch file command: the batch, or a problem whose detail the page shows. 409: the file changed on disk, or the run locks the batch.
+ static IResult FileResult(Func command)
+ {
+ try { return Found(command()); }
+ catch (Exception e) when (e is BatchFileChangedException or InvalidOperationException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status409Conflict); }
+ catch (Exception e) when (e is FileNotFoundException or DirectoryNotFoundException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status404NotFound); }
+ catch (Exception e) when (e is ArgumentException or InvalidDataException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status400BadRequest); }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException) { return Results.Problem(e.Message, statusCode: StatusCodes.Status500InternalServerError); }
+ }
+
+ record LockFile(Uri Url, string Token);
+
+ /// The start URL of the server in the lock file, or null when no server answers there with that token (a stale or missing lock file).
+ public static async Task FindRunningAsync(string appDataFolder)
+ {
+ try
+ {
+ var path = Path.Combine(appDataFolder, LockFileName);
+ if (!File.Exists(path)) return null;
+ if (JsonSerializer.Deserialize(File.ReadAllText(path), JsonSerializerOptions.Web) is not { Url: { } url, Token: { } token }) return null;
+ var startUrl = new Uri(url, "/?token=" + Uri.EscapeDataString(token));
+ using var http = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false, UseCookies = false }) { Timeout = TimeSpan.FromSeconds(3) };
+ using var res = await http.GetAsync(startUrl);
+ return res.StatusCode == HttpStatusCode.Redirect ? startUrl : null; // only our server with this token redirects
+ }
+ catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException or IOException or UriFormatException)
+ {
+ return null;
+ }
+ }
+
+ // ponytail: check-then-write, so two starts in the same instant can both run. Add an OS file lock if that matters.
+ void WriteLock()
+ {
+ File.Delete(lockPath); // a stale file can have other permissions; create it new with mode 600
+ var fs = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write };
+ if (!OperatingSystem.IsWindows()) fs.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
+ using var stream = new FileStream(lockPath, fs);
+ JsonSerializer.Serialize(stream, new LockFile(BaseUrl, Token), JsonSerializerOptions.Web);
+ }
+
+ /// The stream ends when the page goes away, or when the app stops: the server then does not wait for the page.
+ static async IAsyncEnumerable> Events(BatchList list, AppSettings settings, CancellationToken stopping, [EnumeratorCancellation] CancellationToken ct)
+ {
+ using var sub = list.Subscribe();
+ using var end = CancellationTokenSource.CreateLinkedTokenSource(ct, stopping);
+ yield return new SseItem