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();
}
}
}