using System.Text.Json;
using System.Text.Json.Nodes;
using w4c_workflows.Data;
using w4c_workflows.Models;
using w4c_workflows.Models.Credentials;
using w4c_workflows.Models.Nodes;
using w4c_workflows.Services.Audit;
using w4c_workflows.Services.Credentials;
namespace w4c_workflows.Services.Nodes;
/// Outcome of an in-process node-workflow run.
public sealed record NodeWorkflowRunOutcome(bool Succeeded, string? Error, string? Output);
///
/// Runs a persisted node-mode workflow through in
/// the control plane, replacing the linear NextId subprocess chain for
/// node definitions (script workflows keep the subprocess path). Reconstructs
/// the executable graph from the stored tasks +
/// rows, writes a row per executed node for history, and
/// records the run's terminal output/status.
///
public sealed class NodeWorkflowRunner
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
private readonly NodeBlueprintCatalog _catalog;
private readonly NodeGraphRunner _runner;
private readonly CredentialVault _vault;
private readonly ILogger? _logger;
private readonly int _maxSubWorkflowDepth;
private readonly IActionAuditSink? _audit;
public NodeWorkflowRunner(
NodeBlueprintCatalog catalog,
NodeGraphRunner runner,
CredentialVault vault,
ILogger? logger = null,
IActionAuditSink? audit = null)
: this(catalog, runner, vault, logger, SubWorkflowInvoker.DefaultMaxDepth, audit)
{
}
public NodeWorkflowRunner(
NodeBlueprintCatalog catalog,
NodeGraphRunner runner,
CredentialVault vault,
ILogger? logger,
int maxSubWorkflowDepth,
IActionAuditSink? audit = null)
{
_catalog = catalog;
_runner = runner;
_vault = vault;
_logger = logger;
_maxSubWorkflowDepth = maxSubWorkflowDepth;
_audit = audit;
}
public Task RunAsync(
WorkflowsDbContext db,
WorkflowRun run,
Workflow workflow,
string? workingDirectory,
CancellationToken ct)
// A top-level run's chain starts at its own workflow.
=> ExecuteAsync(db, run, workflow, workingDirectory, new[] { workflow.Id }, recordTaskRuns: true, ct);
///
/// Runs a workflow's node graph and owns the run's terminal status. Child runs
/// started by a core.executeWorkflow node call this with their own
/// ancestry chain (for the recursion guard) and may opt out of per-node task
/// history.
///
internal async Task ExecuteAsync(
WorkflowsDbContext db,
WorkflowRun run,
Workflow workflow,
string? workingDirectory,
IReadOnlyList ancestry,
bool recordTaskRuns,
CancellationToken ct)
{
var build = BuildGraph(workflow);
if (build.Graph == null)
return await FailAsync(db, run, build.Error!, ct);
// Resolve every credential the graph references once, so executors only
// see decrypted data. Aliases are unique across a workflow (compile-time
// rule), so a single alias → data map is unambiguous.
IReadOnlyDictionary credentials;
try
{
credentials = await _vault.ResolveAsync(db, run.TenantId, CollectCredentialRefs(build.Graph), ct);
}
catch (CredentialResolutionException ex)
{
return await FailAsync(db, run, ex.Message, ct);
}
var environment = new NodeRunEnvironment
{
TenantId = run.TenantId,
RunId = run.Id.ToString(),
WorkingDirectory = workingDirectory,
Credentials = credentials,
Depth = run.Depth,
Ancestry = ancestry,
// One invoker per run: it carries this run's chain so a nested
// core.executeWorkflow can recurse with a correct depth/cycle view.
SubWorkflows = new SubWorkflowInvoker(
db, this, run, build.TaskIds, ancestry, _maxSubWorkflowDepth, _logger),
};
var listener = BuildListener(db, run, build.TaskIds, recordTaskRuns);
NodeGraphRunResult result;
try
{
result = await _runner.RunAsync(
build.Graph, FlowItemJson.Parse(run.InputJson), environment, listener, ct);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Node workflow run {RunId} threw", run.Id);
return await FailAsync(db, run, ex.Message, ct);
}
if (!result.Succeeded)
return await FailAsync(db, run, result.Failure!.Message, ct);
var output = TerminalOutput(result);
run.Status = RunStatus.Succeeded;
run.OutputJson = output;
run.FinishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
_logger?.LogDebug(
"Node workflow run {RunId} succeeded: {Nodes} nodes executed", run.Id, result.ExecutionOrder.Count);
return new NodeWorkflowRunOutcome(true, null, output);
}
// ------------------------------------------------------------------ graph build
private sealed record GraphBuild(
NodeGraph? Graph,
IReadOnlyDictionary TaskIds,
string? Error);
///
/// Reconstructs the executable graph from the compiled entities. The compile
/// step already validated blueprint/port shape; here we only re-resolve the
/// blueprints (the installed catalog may have changed since compile) and fail
/// the run with a clear message when a type is missing.
///
private GraphBuild BuildGraph(Workflow workflow)
{
var tasks = workflow.Tasks.ToList();
var byId = tasks.ToDictionary(t => t.Id);
var taskIds = tasks.ToDictionary(t => t.Key, t => t.Id, StringComparer.Ordinal);
var nodes = new List(tasks.Count);
foreach (var task in tasks)
{
if (string.IsNullOrWhiteSpace(task.NodeType))
return new GraphBuild(null, taskIds, $"task '{task.Key}' is not a node step");
var blueprint = _catalog.Resolve(task.NodeType!, task.NodeVersion);
if (blueprint == null)
{
return new GraphBuild(null, taskIds,
$"node type '{task.NodeType}' is not installed at version {task.NodeVersion?.ToString() ?? "default"}");
}
nodes.Add(new NodeGraphNode
{
Id = task.Key,
Blueprint = blueprint,
Parameters = ParseObject(task.ParametersJson),
CredentialRefs = ParseRefs(task.CredentialsJson),
RunMode = task.RunMode,
ContinueOnFail = task.ContinueOnFail,
Retry = ParseRetry(task.RetryJson),
});
}
var edges = new List(workflow.TaskEdges.Count);
foreach (var edge in workflow.TaskEdges)
{
if (!byId.TryGetValue(edge.FromTaskId, out var from) || !byId.TryGetValue(edge.ToTaskId, out var to))
return new GraphBuild(null, taskIds, "a task edge references a task that is not in the workflow");
edges.Add(new NodeGraphEdge
{
FromNodeId = from.Key,
FromOutput = edge.FromOutput,
ToNodeId = to.Key,
ToInput = edge.ToInput,
IsLoopBack = edge.IsLoopBack,
});
}
var incoming = nodes.ToDictionary(n => n.Id, _ => 0, StringComparer.Ordinal);
foreach (var edge in edges.Where(e => !e.IsLoopBack))
{
if (incoming.ContainsKey(edge.ToNodeId))
incoming[edge.ToNodeId]++;
}
var roots = nodes.Where(n => incoming[n.Id] == 0).Select(n => n.Id).ToList();
if (roots.Count == 0)
return new GraphBuild(null, taskIds, "node graph has no entry node (it is a cycle)");
if (roots.Count > 1)
return new GraphBuild(null, taskIds,
$"node graph has multiple entry nodes ({string.Join(", ", roots)})");
return new GraphBuild(
new NodeGraph { Nodes = nodes, Edges = edges, EntryNodeId = roots[0] },
taskIds,
null);
}
private static string? TerminalOutput(NodeGraphRunResult result)
{
var last = result.ExecutionOrder.LastOrDefault();
return last == null ? null : FlowItemJson.Serialize(result.OutputOf(last));
}
/// Union of every alias → reference the graph's nodes declare.
private static IReadOnlyDictionary CollectCredentialRefs(NodeGraph graph)
{
var references = new Dictionary(StringComparer.Ordinal);
foreach (var node in graph.Nodes)
{
foreach (var (alias, reference) in node.CredentialRefs)
references[alias] = reference;
}
return references;
}
private async Task FailAsync(
WorkflowsDbContext db, WorkflowRun run, string error, CancellationToken ct)
{
_logger?.LogWarning("Node workflow run {RunId} failed: {Error}", run.Id, error);
run.Status = RunStatus.Failed;
run.Error = error;
run.FinishedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return new NodeWorkflowRunOutcome(false, error, null);
}
// ------------------------------------------------------------------ history
///
/// Composes the per-run listeners: task history when requested plus the
/// audit listener when a sink is configured. Returns null when neither is
/// needed so the kernel skips the callbacks entirely.
///
private INodeRunListener? BuildListener(
WorkflowsDbContext db,
WorkflowRun run,
IReadOnlyDictionary taskIds,
bool recordTaskRuns)
{
var listeners = new List(2);
if (recordTaskRuns)
listeners.Add(new TaskRunHistory(db, run, taskIds));
if (_audit != null)
listeners.Add(new AuditingNodeRunListener(_audit, run));
return listeners.Count switch
{
0 => null,
1 => listeners[0],
_ => new CompositeNodeRunListener(listeners),
};
}
///
/// Writes one per executed node: created running
/// when the node starts, completed with its output and (if it errored) the
/// failure when it finishes. Nodes skipped for lack of input leave no row.
///
private sealed class TaskRunHistory : INodeRunListener
{
private readonly WorkflowsDbContext _db;
private readonly WorkflowRun _run;
private readonly IReadOnlyDictionary _taskIds;
private readonly Dictionary _rows = new(StringComparer.Ordinal);
public TaskRunHistory(WorkflowsDbContext db, WorkflowRun run, IReadOnlyDictionary taskIds)
{
_db = db;
_run = run;
_taskIds = taskIds;
}
public async Task NodeStartedAsync(
NodeGraphNode node, IReadOnlyList> inputs, CancellationToken ct)
{
if (!_taskIds.TryGetValue(node.Id, out var taskId))
return;
var row = new TaskRun
{
Id = Guid.NewGuid(),
RunId = _run.Id,
TaskId = taskId,
Attempt = 1,
Status = TaskRunStatus.Running,
InputJson = MainOutput(inputs),
StartedAt = DateTime.UtcNow,
};
_db.TaskRuns.Add(row);
_rows[node.Id] = row;
await _db.SaveChangesAsync(ct);
}
public async Task NodeFinishedAsync(
NodeGraphNode node,
IReadOnlyList> outputs,
NodeFailure? failure,
CancellationToken ct)
{
if (!_rows.TryGetValue(node.Id, out var row))
return;
row.Status = failure == null ? TaskRunStatus.Succeeded : TaskRunStatus.Failed;
row.OutputJson = MainOutput(outputs);
row.Error = failure?.Message;
row.FinishedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
private static string? MainOutput(IReadOnlyList> ports)
=> ports.Count > 0 ? FlowItemJson.Serialize(ports[0]) : null;
}
// ------------------------------------------------------------------ parsing
private static JsonObject ParseObject(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new JsonObject();
try
{
return JsonNode.Parse(json) as JsonObject ?? new JsonObject();
}
catch (JsonException)
{
return new JsonObject();
}
}
private static IReadOnlyDictionary ParseRefs(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return new Dictionary();
try
{
return JsonSerializer.Deserialize>(json, Json)
?? new Dictionary();
}
catch (JsonException)
{
return new Dictionary();
}
}
private static TaskRetryDefinition? ParseRetry(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize(json, Json);
}
catch (JsonException)
{
return null;
}
}
}