[WIP] workflow builder

This commit is contained in:
2026-02-23 20:45:10 -06:00
parent d629da32fa
commit 53a3fbb6b1
66 changed files with 7887 additions and 1608 deletions

788
web/src/types/workflow.ts Normal file
View File

@@ -0,0 +1,788 @@
/**
* Workflow Builder Types
*
* These types represent the client-side workflow builder state
* and map to the backend workflow YAML format.
*
* Uses the Orquesta-style task transition model where each task has a `next`
* list of transitions. Each transition specifies:
* - `when` — a condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}")
* - `publish` — variables to publish into the workflow context
* - `do` — next tasks to invoke when the condition is met
*/
/** Position of a node on the canvas */
export interface NodePosition {
x: number;
y: number;
}
/**
* A single task transition evaluated after task completion.
*
* Transitions are evaluated in order. When `when` is not defined,
* the transition is unconditional (fires on any completion).
*/
export interface TaskTransition {
/** Condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}") */
when?: string;
/** Variables to publish into the workflow context on this transition */
publish?: PublishDirective[];
/** Next tasks to invoke when transition criteria is met */
do?: string[];
/** Custom display label for the transition (overrides auto-derived label) */
label?: string;
/** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */
color?: string;
}
/** A task node in the workflow builder */
export interface WorkflowTask {
/** Unique ID for the builder (not persisted) */
id: string;
/** Task name (used in YAML) */
name: string;
/** Action reference (e.g., "core.echo") */
action: string;
/** Input parameters (template strings or values) */
input: Record<string, unknown>;
/** Task transitions — evaluated in order after task completes */
next?: TaskTransition[];
/** Delay in seconds before executing this task */
delay?: number;
/** Retry configuration */
retry?: RetryConfig;
/** Timeout in seconds */
timeout?: number;
/** With-items iteration expression */
with_items?: string;
/** Batch size for with-items */
batch_size?: number;
/** Concurrency limit for with-items */
concurrency?: number;
/** Join barrier count */
join?: number;
/** Visual position on canvas */
position: NodePosition;
}
/** Retry configuration */
export interface RetryConfig {
/** Number of retry attempts */
count: number;
/** Initial delay in seconds */
delay: number;
/** Backoff strategy */
backoff?: "constant" | "linear" | "exponential";
/** Maximum delay in seconds */
max_delay?: number;
/** Only retry on specific error conditions */
on_error?: string;
}
/** Variable publishing directive */
export type PublishDirective = Record<string, string>;
/**
* Transition handle presets for the visual builder.
*
* These map to common `when` expressions and provide a quick way
* to create transitions without typing expressions manually.
*/
export type TransitionPreset = "succeeded" | "failed" | "always";
/** The `when` expression for each preset (undefined = unconditional) */
export const PRESET_WHEN: Record<TransitionPreset, string | undefined> = {
succeeded: "{{ succeeded() }}",
failed: "{{ failed() }}",
always: undefined,
};
/** Human-readable labels for presets */
export const PRESET_LABELS: Record<TransitionPreset, string> = {
succeeded: "On Success",
failed: "On Failure",
always: "Always",
};
/**
* Classify a `when` expression into an edge visual type.
* Used for edge coloring and labeling.
*/
export type EdgeType = "success" | "failure" | "complete" | "custom";
export function classifyTransitionWhen(when?: string): EdgeType {
if (!when) return "complete"; // unconditional
const lower = when.toLowerCase().replace(/\s+/g, "");
if (lower.includes("succeeded()")) return "success";
if (lower.includes("failed()")) return "failure";
return "custom";
}
/** Human-readable short label for a `when` expression */
export function transitionLabel(when?: string, customLabel?: string): string {
if (customLabel) return customLabel;
if (!when) return "always";
const lower = when.toLowerCase().replace(/\s+/g, "");
if (lower.includes("succeeded()")) return "succeeded";
if (lower.includes("failed()")) return "failed";
// Truncate custom expressions for display
if (when.length > 30) return when.slice(0, 27) + "...";
return when;
}
/** An edge/connection between two tasks */
export interface WorkflowEdge {
/** Source task ID */
from: string;
/** Target task ID */
to: string;
/** Visual type of transition (derived from `when`) */
type: EdgeType;
/** Label to display on the edge */
label?: string;
/** Index of the transition in the source task's `next` array */
transitionIndex: number;
/** Custom color override for the edge (CSS color string) */
color?: string;
}
/** Complete workflow builder state */
export interface WorkflowBuilderState {
/** Workflow name (used to derive ref and filename) */
name: string;
/** Human-readable label */
label: string;
/** Description */
description: string;
/** Semantic version */
version: string;
/** Pack reference this workflow belongs to */
packRef: string;
/** Input parameter schema (flat format) */
parameters: Record<string, ParamDefinition>;
/** Output schema (flat format) */
output: Record<string, ParamDefinition>;
/** Workflow-scoped variables */
vars: Record<string, unknown>;
/** Task nodes */
tasks: WorkflowTask[];
/** Tags */
tags: string[];
/** Whether the workflow is enabled */
enabled: boolean;
}
/** Parameter definition in flat schema format */
export interface ParamDefinition {
type: string;
description?: string;
required?: boolean;
secret?: boolean;
default?: unknown;
enum?: string[];
}
/** Workflow definition as stored in the YAML file / API */
export interface WorkflowYamlDefinition {
ref: string;
label: string;
description?: string;
version: string;
parameters?: Record<string, unknown>;
output?: Record<string, unknown>;
vars?: Record<string, unknown>;
tasks: WorkflowYamlTask[];
output_map?: Record<string, string>;
tags?: string[];
}
/** Transition as represented in YAML format */
export interface WorkflowYamlTransition {
when?: string;
publish?: PublishDirective[];
do?: string[];
/** Custom display label for the transition */
label?: string;
/** Custom color for the transition edge */
color?: string;
}
/** Task as represented in YAML format */
export interface WorkflowYamlTask {
name: string;
action?: string;
input?: Record<string, unknown>;
delay?: number;
with_items?: string;
batch_size?: number;
concurrency?: number;
retry?: RetryConfig;
timeout?: number;
next?: WorkflowYamlTransition[];
join?: number;
}
/** Request to save a workflow file to disk and sync to DB */
export interface SaveWorkflowFileRequest {
/** Workflow name (becomes filename: {name}.workflow.yaml) */
name: string;
/** Human-readable label */
label: string;
/** Description */
description?: string;
/** Semantic version */
version: string;
/** Pack reference */
pack_ref: string;
/** The full workflow definition as JSON */
definition: WorkflowYamlDefinition;
/** Parameter schema (flat format) */
param_schema?: Record<string, unknown>;
/** Output schema (flat format) */
out_schema?: Record<string, unknown>;
/** Tags */
tags?: string[];
/** Whether the workflow is enabled */
enabled?: boolean;
}
/** An action summary used in the action palette */
export interface PaletteAction {
id: number;
ref: string;
label: string;
description: string;
pack_ref: string;
param_schema: Record<string, unknown> | null;
out_schema: Record<string, unknown> | null;
}
// ---------------------------------------------------------------------------
// Conversion functions
// ---------------------------------------------------------------------------
/**
* Check if two values are deeply equal for the purpose of default comparison.
* Handles primitives, arrays, and plain objects.
*/
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a == null || b == null) return false;
if (typeof a !== typeof b) return false;
if (typeof a !== "object") return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((v, i) => deepEqual(v, b[i]));
}
const aObj = a as Record<string, unknown>;
const bObj = b as Record<string, unknown>;
const aKeys = Object.keys(aObj);
const bKeys = Object.keys(bObj);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
}
/**
* Strip input values that match their schema defaults.
* Returns a new object containing only user-modified values.
*/
export function stripDefaultInputs(
input: Record<string, unknown>,
paramSchema: Record<string, unknown> | null | undefined,
): Record<string, unknown> {
if (!paramSchema || typeof paramSchema !== "object") return input;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
const schemaDef = paramSchema[key] as
| { default?: unknown }
| null
| undefined;
if (
schemaDef &&
schemaDef.default !== undefined &&
deepEqual(value, schemaDef.default)
) {
continue; // skip — matches default
}
// Also skip empty strings when there's no default (user never filled it in)
if (value === "" && (!schemaDef || schemaDef.default === undefined)) {
continue;
}
result[key] = value;
}
return result;
}
/**
* Convert builder state to YAML definition for saving.
*
* When `actionSchemas` is provided (a map of action ref → param_schema),
* input values that match their schema defaults are omitted from the output
* so only user-modified parameters appear in the generated YAML.
*/
export function builderStateToDefinition(
state: WorkflowBuilderState,
actionSchemas?: Map<string, Record<string, unknown> | null>,
): WorkflowYamlDefinition {
const tasks: WorkflowYamlTask[] = state.tasks.map((task) => {
const yamlTask: WorkflowYamlTask = {
name: task.name,
};
if (task.action) {
yamlTask.action = task.action;
}
// Filter input: strip values that match schema defaults
const schema = actionSchemas?.get(task.action);
const effectiveInput = schema
? stripDefaultInputs(task.input, schema)
: task.input;
if (Object.keys(effectiveInput).length > 0) {
yamlTask.input = effectiveInput;
}
if (task.delay) yamlTask.delay = task.delay;
if (task.with_items) yamlTask.with_items = task.with_items;
if (task.batch_size) yamlTask.batch_size = task.batch_size;
if (task.concurrency) yamlTask.concurrency = task.concurrency;
if (task.retry) yamlTask.retry = task.retry;
if (task.timeout) yamlTask.timeout = task.timeout;
if (task.join) yamlTask.join = task.join;
// Serialize transitions as `next` array
if (task.next && task.next.length > 0) {
yamlTask.next = task.next.map((t) => {
const yt: WorkflowYamlTransition = {};
if (t.when) yt.when = t.when;
if (t.publish && t.publish.length > 0) yt.publish = t.publish;
if (t.do && t.do.length > 0) yt.do = t.do;
if (t.label) yt.label = t.label;
if (t.color) yt.color = t.color;
return yt;
});
}
return yamlTask;
});
const definition: WorkflowYamlDefinition = {
ref: `${state.packRef}.${state.name}`,
label: state.label,
version: state.version,
tasks,
};
if (state.description) {
definition.description = state.description;
}
if (Object.keys(state.parameters).length > 0) {
definition.parameters = state.parameters;
}
if (Object.keys(state.output).length > 0) {
definition.output = state.output;
}
if (Object.keys(state.vars).length > 0) {
definition.vars = state.vars;
}
if (state.tags.length > 0) {
definition.tags = state.tags;
}
return definition;
}
// ---------------------------------------------------------------------------
// Legacy format conversion helpers
// ---------------------------------------------------------------------------
/** Legacy task fields that may appear in older workflow definitions */
interface LegacyYamlTask extends WorkflowYamlTask {
on_success?: string;
on_failure?: string;
on_complete?: string;
on_timeout?: string;
decision?: { when?: string; next: string; default?: boolean }[];
publish?: PublishDirective[];
}
/**
* Convert legacy on_success/on_failure/etc fields to `next` transitions.
* This allows the builder to load workflows saved in the old format.
*/
function legacyTransitionsToNext(task: LegacyYamlTask): TaskTransition[] {
const transitions: TaskTransition[] = [];
if (task.on_success) {
transitions.push({
when: "{{ succeeded() }}",
do: [task.on_success],
});
}
if (task.on_failure) {
transitions.push({
when: "{{ failed() }}",
do: [task.on_failure],
});
}
if (task.on_complete) {
// on_complete = unconditional (fires regardless of success/failure)
transitions.push({
do: [task.on_complete],
});
}
if (task.on_timeout) {
transitions.push({
when: "{{ timed_out() }}",
do: [task.on_timeout],
});
}
// Convert legacy decision branches
if (task.decision) {
for (const branch of task.decision) {
transitions.push({
when: branch.when || undefined,
do: [branch.next],
});
}
}
// If legacy task had publish but no transitions, create a publish-only transition
if (task.publish && task.publish.length > 0 && transitions.length === 0) {
transitions.push({
when: "{{ succeeded() }}",
publish: task.publish,
});
} else if (
task.publish &&
task.publish.length > 0 &&
transitions.length > 0
) {
// Attach publish to the first succeeded transition, or the first transition
const succeededIdx = transitions.findIndex(
(t) => t.when && t.when.toLowerCase().includes("succeeded()"),
);
const idx = succeededIdx >= 0 ? succeededIdx : 0;
transitions[idx].publish = task.publish;
}
return transitions;
}
/**
* Convert a YAML definition back to builder state (for editing existing workflows).
* Supports both new `next` format and legacy `on_success`/`on_failure` format.
*/
export function definitionToBuilderState(
definition: WorkflowYamlDefinition,
packRef: string,
name: string,
): WorkflowBuilderState {
const tasks: WorkflowTask[] = (definition.tasks || []).map(
(rawTask, index) => {
const task = rawTask as LegacyYamlTask;
// Determine transitions: prefer `next` if present, otherwise convert legacy fields
let next: TaskTransition[] | undefined;
if (task.next && task.next.length > 0) {
next = task.next.map((t) => ({
when: t.when,
publish: t.publish,
do: t.do,
label: t.label,
color: t.color,
}));
} else {
const converted = legacyTransitionsToNext(task);
next = converted.length > 0 ? converted : undefined;
}
return {
id: `task-${index}-${Date.now()}`,
name: task.name,
action: task.action || "",
input: task.input || {},
next,
delay: task.delay,
retry: task.retry,
timeout: task.timeout,
with_items: task.with_items,
batch_size: task.batch_size,
concurrency: task.concurrency,
join: task.join,
position: {
x: 300,
y: 80 + index * 160,
},
};
},
);
return {
name,
label: definition.label,
description: definition.description || "",
version: definition.version,
packRef,
parameters: (definition.parameters || {}) as Record<
string,
ParamDefinition
>,
output: (definition.output || {}) as Record<string, ParamDefinition>,
vars: definition.vars || {},
tasks,
tags: definition.tags || [],
enabled: true,
};
}
// ---------------------------------------------------------------------------
// Edge derivation
// ---------------------------------------------------------------------------
/**
* Derive visual edges from task transitions.
*
* Each entry in a task's `next` array can target multiple tasks via `do`.
* Each target produces a separate edge with the same visual type/label.
*/
export function deriveEdges(tasks: WorkflowTask[]): WorkflowEdge[] {
const edges: WorkflowEdge[] = [];
const taskNameToId = new Map<string, string>();
for (const task of tasks) {
taskNameToId.set(task.name, task.id);
}
for (const task of tasks) {
if (!task.next) continue;
for (let ti = 0; ti < task.next.length; ti++) {
const transition = task.next[ti];
const edgeType = classifyTransitionWhen(transition.when);
const label = transitionLabel(transition.when, transition.label);
if (transition.do) {
for (const targetName of transition.do) {
const targetId = taskNameToId.get(targetName);
if (targetId) {
edges.push({
from: task.id,
to: targetId,
type: edgeType,
label,
transitionIndex: ti,
color: transition.color,
});
}
}
}
}
}
return edges;
}
// ---------------------------------------------------------------------------
// Task transition helpers
// ---------------------------------------------------------------------------
/**
* Find or create a transition in a task's `next` array that matches a preset.
*
* If a transition with a matching `when` expression already exists, returns
* its index. Otherwise, appends a new transition and returns the new index.
*/
export function findOrCreateTransition(
task: WorkflowTask,
preset: TransitionPreset,
): { next: TaskTransition[]; index: number } {
const whenExpr = PRESET_WHEN[preset];
const next = [...(task.next || [])];
// Look for an existing transition with the same `when`
const existingIndex = next.findIndex((t) => {
if (whenExpr === undefined) return t.when === undefined;
return (
t.when?.toLowerCase().replace(/\s+/g, "") ===
whenExpr.toLowerCase().replace(/\s+/g, "")
);
});
if (existingIndex >= 0) {
return { next, index: existingIndex };
}
// Create new transition
const newTransition: TaskTransition = {};
if (whenExpr) newTransition.when = whenExpr;
next.push(newTransition);
return { next, index: next.length - 1 };
}
/**
* Add a target task to a transition's `do` list.
* If the target is already in the list, this is a no-op.
* Returns the updated `next` array.
*/
export function addTransitionTarget(
task: WorkflowTask,
preset: TransitionPreset,
targetTaskName: string,
): TaskTransition[] {
const { next, index } = findOrCreateTransition(task, preset);
const transition = { ...next[index] };
const doList = [...(transition.do || [])];
if (!doList.includes(targetTaskName)) {
doList.push(targetTaskName);
}
transition.do = doList;
next[index] = transition;
return next;
}
/**
* Remove all references to a task name from all transitions.
* Cleans up transitions that become empty (no `do` and no `publish`).
*/
export function removeTaskFromTransitions(
next: TaskTransition[] | undefined,
taskName: string,
): TaskTransition[] | undefined {
if (!next) return undefined;
const cleaned = next
.map((t) => {
if (!t.do || !t.do.includes(taskName)) return t;
const newDo = t.do.filter((name) => name !== taskName);
return { ...t, do: newDo.length > 0 ? newDo : undefined };
})
// Keep transitions that still have `do` targets or `publish` directives
.filter(
(t) => (t.do && t.do.length > 0) || (t.publish && t.publish.length > 0),
);
return cleaned.length > 0 ? cleaned : undefined;
}
// ---------------------------------------------------------------------------
// Utility functions
// ---------------------------------------------------------------------------
/**
* Generate a unique task ID
*/
export function generateTaskId(): string {
return `task-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Create a new empty task
*/
export function createEmptyTask(
name: string,
position: NodePosition,
): WorkflowTask {
return {
id: generateTaskId(),
name,
action: "",
input: {},
position,
};
}
/**
* Generate a unique task name that doesn't conflict with existing tasks
*/
export function generateUniqueTaskName(
existingTasks: WorkflowTask[],
baseName: string = "task",
): string {
const existingNames = new Set(existingTasks.map((t) => t.name));
let counter = existingTasks.length + 1;
let name = `${baseName}_${counter}`;
while (existingNames.has(name)) {
counter++;
name = `${baseName}_${counter}`;
}
return name;
}
/**
* Validate a workflow builder state and return any errors
*/
export function validateWorkflow(state: WorkflowBuilderState): string[] {
const errors: string[] = [];
if (!state.name.trim()) {
errors.push("Workflow name is required");
}
if (!state.label.trim()) {
errors.push("Workflow label is required");
}
if (!state.version.trim()) {
errors.push("Workflow version is required");
}
if (!state.packRef) {
errors.push("Pack reference is required");
}
if (state.tasks.length === 0) {
errors.push("Workflow must have at least one task");
}
// Check for duplicate task names
const taskNames = new Set<string>();
for (const task of state.tasks) {
if (taskNames.has(task.name)) {
errors.push(`Duplicate task name: "${task.name}"`);
}
taskNames.add(task.name);
}
// Check that tasks have an action reference
for (const task of state.tasks) {
if (!task.action) {
errors.push(`Task "${task.name}" must have an action assigned`);
}
}
// Check that all transition targets reference existing tasks
for (const task of state.tasks) {
if (!task.next) continue;
for (let ti = 0; ti < task.next.length; ti++) {
const transition = task.next[ti];
if (!transition.do) continue;
for (const targetName of transition.do) {
if (!taskNames.has(targetName)) {
const whenLabel = transition.when
? ` (when: ${transition.when})`
: " (always)";
errors.push(
`Task "${task.name}" transition${whenLabel} references non-existent task "${targetName}"`,
);
}
}
}
}
return errors;
}