w4c-workflows-api/Services/Nodes/LegacyWorkflowLowerer.cs
2026-09-13 11:35:17 +03:00

125 lines
4.6 KiB
C#

using w4c_workflows.Models;
using w4c_workflows.Models.Nodes;
namespace w4c_workflows.Services.Nodes;
/// <summary>
/// Lowers a legacy script definition (<c>language</c> + <c>entry</c> tasks driven
/// by the linear <c>next</c>/<c>onError</c> chain) into an equivalent node-mode
/// definition where every step is a synthetic <see cref="CodeNodeType"/> node.
/// The result is an ordinary <see cref="WorkflowDefinition"/> and can be compiled
/// by <see cref="NodeGraphCompiler"/>.
///
/// Script execution is unchanged: the <c>core.code</c> executor delegates to the
/// existing <see cref="Execution.IScriptExecutor"/> runtime, so the subprocess
/// path is preserved while the edge kernel owns scheduling.
///
/// This is the S1 migration bridge. It is wired into <see cref="WorkflowCompiler"/>
/// behind <c>Workflows:LowerLegacyScripts</c> (default off, function mode only):
/// durable/handler scripts keep the linear engine because the node kernel does
/// not checkpoint/resume, and already-persisted script workflows keep running
/// unchanged until an operator opts in.
///
/// NOTE: the node kernel passes per-node data as <c>FlowItem</c> envelopes
/// (<c>{ "json": … }</c>), while the linear engine passes a task's raw JSON output
/// to its successor. Lowering therefore changes the exact input JSON a script
/// receives. The switch must not be enabled for a workflow whose scripts depend on
/// the raw shape until that contract is bridged.
/// </summary>
public static class LegacyWorkflowLowerer
{
public const string CodeNodeType = "core.code";
/// <summary>Id of the synthetic node representing the header's entry script.</summary>
public const string EntryStepId = "__entry";
/// <summary>
/// Builds the node-mode definition. The header entry becomes a synthetic node
/// whose <c>next</c> is the workflow's chain head; each task keeps its id,
/// <c>next</c> and <c>onError</c>, which the node compiler lowers into ports.
/// </summary>
public static WorkflowDefinition ToNodeDefinition(WorkflowDefinition def)
{
var tasks = def.Tasks ?? new List<TaskDefinition>();
var entryId = UniqueId(EntryStepId, tasks);
var headId = WorkflowGraph.Compute(def).HeadId;
var lowered = new WorkflowDefinition
{
Name = def.Name,
Version = def.Version,
Mode = def.Mode,
Target = def.Target,
Server = def.Server,
Trigger = def.Trigger,
Env = def.Env,
Entry = null,
Language = null,
Tasks = new List<TaskDefinition>(tasks.Count + 1),
};
if (def.Entry != null)
{
lowered.Tasks.Add(new TaskDefinition
{
Id = entryId,
Parent = "root",
Next = headId,
Node = new NodeTaskDefinition { Type = CodeNodeType },
Parameters = CodeParameters(def.Language, def.Entry),
});
}
foreach (var task in tasks)
{
lowered.Tasks.Add(new TaskDefinition
{
Id = task.Id,
Parent = "root",
Next = task.Next,
OnError = task.OnError,
Node = new NodeTaskDefinition { Type = CodeNodeType },
Parameters = CodeParameters(task.Language ?? def.Language, task.Entry),
});
}
return lowered;
}
private static Dictionary<string, object?> CodeParameters(string? language, EntryDefinition? entry)
{
var parameters = new Dictionary<string, object?>
{
[ParameterNames.Language] = language,
[ParameterNames.EntryFile] = entry?.File,
};
if (!string.IsNullOrWhiteSpace(entry?.Function))
parameters[ParameterNames.EntryFunction] = entry.Function;
return parameters;
}
private static string UniqueId(string preferred, IReadOnlyList<TaskDefinition> tasks)
{
var taken = tasks.Select(t => t.Id).Where(id => id != null).ToHashSet(StringComparer.Ordinal);
if (!taken.Contains(preferred))
return preferred;
for (var i = 1; ; i++)
{
var candidate = $"{preferred}{i}";
if (!taken.Contains(candidate))
return candidate;
}
}
/// <summary>Parameter names shared with the <c>core.code</c> blueprint.</summary>
private static class ParameterNames
{
public const string Language = "language";
public const string EntryFile = "entryFile";
public const string EntryFunction = "entryFunction";
}
}