using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Text; using Microsoft.Extensions.Logging; using Volcanic.AgenticCore.Abstractions.Processes; namespace Volcanic.AgenticCore.Extensions.Scheduler; /// One watcher run. becomes the script's TZ (the target's zone). public sealed record ScriptRequest(string Command, string Cwd, TimeSpan Timeout, string Target, string JobId, string? TimeZoneId); /// What a watcher run means for the job. public abstract record ScriptOutcome { private ScriptOutcome() { } /// Exit 0, empty stdout after trimming: the condition isn't met. Costs nothing. public sealed record Silent : ScriptOutcome; /// Exit 0 with output: the condition is met; (trimmed) goes to the agent. public sealed record Output(string Text) : ScriptOutcome; /// Non-zero exit, timeout, a hook that threw, or a shell that didn't start. public sealed record Failed(int? ExitCode, string StderrTail, bool TimedOut) : ScriptOutcome; } /// Runs watcher scripts. Faked in scheduler tests. public interface IScriptRunner { /// Never throws for a script problem (that's ); throws after killing the script when is cancelled. Task RunAsync(ScriptRequest request, CancellationToken cancellationToken); } /// /// Runs a watcher with the bash tool's shell in the target's workspace, stdin closed, with the command, cwd and /// environment from the host's (): the same /// filtered environment as bash, TZ = the target's zone, host hooks applied. No session-level /// runs: a job fires without a live session. /// public sealed class ScriptRunner(SchedulerOptions options, IProcessSpawnPolicy policy, ILogger logger, TimeProvider? time = null) : IScriptRunner { public const int StderrTailLines = 20; private static readonly TimeSpan PipeGrace = TimeSpan.FromSeconds(5); private readonly TimeProvider _time = time ?? TimeProvider.System; public async Task RunAsync(ScriptRequest request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); ProcessSpawnContext spawn; try { spawn = policy.Prepare(new ProcessSpawnRequest { Purpose = ProcessPurpose.ScheduledScript, Command = request.Command, Cwd = request.Cwd, Scope = request.Target, Origin = request.JobId, TimeZoneId = request.TimeZoneId, }); } catch (Exception ex) { return new ScriptOutcome.Failed(null, $"process environment hook failed: {ex.Message}", false); } var psi = StartInfo(spawn.Command); spawn.ApplyTo(psi); // cwd and exactly the prepared environment using var process = new Process { StartInfo = psi }; try { Directory.CreateDirectory(spawn.Cwd); process.Start(); } catch (Exception ex) when (ex is Win32Exception or InvalidOperationException or IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) // e.g. a workspace path that can't be created { return new ScriptOutcome.Failed(null, $"failed to start {psi.FileName}: {ex.Message}", false); } process.StandardInput.Close(); var stdout = new OutputCapture(); var stderr = new OutputCapture(); var pumps = Task.WhenAll(PumpAsync(process.StandardOutput, stdout), PumpAsync(process.StandardError, stderr)); using var timeout = new CancellationTokenSource(request.Timeout, _time); using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); var timedOut = false; try { await process.WaitForExitAsync(linked.Token).ConfigureAwait(false); } catch (OperationCanceledException) { Kill(process, request); timedOut = !cancellationToken.IsCancellationRequested; } await DrainAsync(pumps, request).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); if (timedOut) return new ScriptOutcome.Failed(null, Tail(stderr.ToString()), true); if (process.ExitCode != 0) return new ScriptOutcome.Failed(process.ExitCode, Tail(stderr.ToString()), false); var text = stdout.ToString().Trim(); return text.Length == 0 ? new ScriptOutcome.Silent() : new ScriptOutcome.Output(text); } /// /// The bash tool's shell: /bin/sh -c <command> through . On /// Windows cmd.exe doesn't parse the MSVC quoting ArgumentList produces, so the command goes through /// as /d /s /c "<command>", exactly as bash does. /// A configured always uses ArgumentList. /// internal ProcessStartInfo StartInfo(string command) { var psi = new ProcessStartInfo { RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; if (options.Shell.Count > 0) { psi.FileName = options.Shell[0]; foreach (var argument in options.Shell.Skip(1)) psi.ArgumentList.Add(argument); psi.ArgumentList.Add(command); } else if (OperatingSystem.IsWindows()) { psi.FileName = "cmd.exe"; psi.Arguments = $"/d /s /c \"{command}\""; } else { psi.FileName = "/bin/sh"; psi.ArgumentList.Add("-c"); psi.ArgumentList.Add(command); } return psi; } private void Kill(Process process, ScriptRequest request) { try { process.Kill(entireProcessTree: true); process.WaitForExit(PipeGrace); } catch (Exception ex) when (ex is InvalidOperationException or Win32Exception or NotSupportedException) { logger.LogDebug(ex, "Job {JobId}: killing the script failed (it may have exited)", request.JobId); } } /// Waits for both output pipes to close, at most (a surviving grandchild may hold them). private async Task DrainAsync(Task pumps, ScriptRequest request) { try { await pumps.WaitAsync(PipeGrace, CancellationToken.None).ConfigureAwait(false); } catch (TimeoutException) { logger.LogWarning("Job {JobId}: the script's output pipes stayed open after it ended", request.JobId); } } private static async Task PumpAsync(StreamReader reader, OutputCapture capture) { var buffer = new char[4096]; try { int read; while ((read = await reader.ReadAsync(buffer.AsMemory(), CancellationToken.None).ConfigureAwait(false)) > 0) capture.Append(buffer.AsSpan(0, read)); } catch (Exception ex) when (ex is IOException or ObjectDisposedException) { // The pipe broke or the process was disposed: keep what was read. } } /// The last lines. internal static string Tail(string text) { var lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).TrimEnd('\n').Split('\n'); return string.Join('\n', lines.TakeLast(StderrTailLines)); } } /// Keeps the first and the last characters of a stream. internal sealed class OutputCapture { public const int HeadChars = 48 * 1024; public const int TailChars = 16 * 1024; private readonly StringBuilder _head = new(); private readonly char[] _tail = new char[TailChars]; private readonly Lock _lock = new(); private int _tailStart; private int _tailLength; private long _dropped; public void Append(string text) => Append(text.AsSpan()); public void Append(ReadOnlySpan text) { lock (_lock) { var toHead = Math.Min(text.Length, HeadChars - _head.Length); if (toHead > 0) _head.Append(text[..toHead]); foreach (var c in text[Math.Max(toHead, 0)..]) { if (_tailLength == TailChars) { _tail[_tailStart] = c; _tailStart = (_tailStart + 1) % TailChars; _dropped++; } else { _tail[(_tailStart + _tailLength) % TailChars] = c; _tailLength++; } } } } public override string ToString() { lock (_lock) { var sb = new StringBuilder(_head.Length + _tailLength + 64).Append(_head); if (_dropped > 0) sb.Append(CultureInfo.InvariantCulture, $"\n[… {_dropped} characters omitted …]\n"); for (var i = 0; i < _tailLength; i++) sb.Append(_tail[(_tailStart + i) % TailChars]); return sb.ToString(); } } }