using System.Text.Json;
using System.Text.Json.Serialization;
namespace w4c_workflows.Services.Execution;
///
/// Shared helpers for the subprocess-backed executors: environment construction,
/// working-directory resolution and the stdout → JSON output interpretation.
///
/// Output contract: a program signals its result by printing one JSON value on
/// the last non-empty line of stdout. If that line is not valid JSON the whole
/// (trimmed) stdout is wrapped as a JSON string instead, so every output is
/// storable JSON.
///
internal static class ExecutionHelpers
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static string ResolveWorkingDir(TaskInvocation invocation)
=> string.IsNullOrWhiteSpace(invocation.WorkingDir)
? Directory.GetCurrentDirectory()
: Path.GetFullPath(invocation.WorkingDir);
///
/// Resolves a task's entry file inside its working directory. A rooted path,
/// or one that escapes via .., is rejected — otherwise a workflow could
/// read or execute files outside its own run directory (Path.Combine
/// silently drops the root when the second argument is absolute).
///
public static bool TryResolveEntryPath(TaskInvocation invocation, out string entryPath, out string? error)
{
var root = ResolveWorkingDir(invocation);
var candidate = invocation.EntryFile;
if (string.IsNullOrWhiteSpace(candidate))
{
entryPath = string.Empty;
error = "entry.file is empty";
return false;
}
if (Path.IsPathRooted(candidate))
{
entryPath = string.Empty;
error = $"entry file '{candidate}' must be relative to the task working directory";
return false;
}
var resolved = Path.GetFullPath(Path.Combine(root, candidate));
var rootWithSeparator = root.EndsWith(Path.DirectorySeparatorChar)
? root
: root + Path.DirectorySeparatorChar;
var comparison = OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal;
if (!resolved.StartsWith(rootWithSeparator, comparison))
{
entryPath = string.Empty;
error = $"entry file '{candidate}' escapes the task working directory";
return false;
}
entryPath = resolved;
error = null;
return true;
}
/// WF_* runtime context + the workflow/task-declared env, merged.
public static IReadOnlyDictionary BuildEnvironment(TaskInvocation invocation)
{
var env = new Dictionary(StringComparer.Ordinal)
{
["WF_TENANT_ID"] = invocation.TenantId,
["WF_RUN_ID"] = invocation.RunId,
["WF_TASK_ID"] = invocation.TaskId,
["WF_TASK_KEY"] = invocation.TaskKey,
["WF_INPUT"] = invocation.Input ?? "{}",
};
foreach (var (key, value) in invocation.Env)
env[key] = value;
return env;
}
public static ExecutionResult ToResult(ProcessOutput output, TimeSpan duration)
{
var success = output.ExitCode == 0;
var error = success
? null
: LastNonEmptyLine(output.Stderr) ?? $"exit code {output.ExitCode}";
return new ExecutionResult(
success,
output.ExitCode,
output.Stdout,
output.Stderr,
success ? ParseOutput(output.Stdout) : null,
error,
duration);
}
public static string? LastNonEmptyLine(string? text)
{
if (string.IsNullOrEmpty(text))
return null;
foreach (var line in text.Split('\n'))
{
var trimmed = line.Trim('\r', ' ', '\t');
if (trimmed.Length > 0)
return trimmed;
}
return null;
}
private static string? ParseOutput(string stdout)
{
var line = LastNonEmptyLine(stdout);
if (line == null)
return null;
try
{
using var _ = JsonDocument.Parse(line);
return line; // already valid JSON — store it verbatim
}
catch (JsonException)
{
return JsonSerializer.Serialize(stdout.Trim(), Json);
}
}
}