From c8d9a3a2ecc260c17c82a3a8b6ef1eb8bbf93550 Mon Sep 17 00:00:00 2001 From: Colin Dawson Date: Thu, 12 Mar 2026 23:46:34 +0000 Subject: [PATCH] Now able to delete a workflow template. Also refactors the task list sort order to no longer use the predcessors. --- .../WorkflowTemplateManager.tsx | 2 +- .../components/workflowGraphUtils.ts | 184 +++++++++++++++--- .../services/WorkflowTemplateService.ts | 9 + 3 files changed, 162 insertions(+), 33 deletions(-) diff --git a/src/modules/manager/workflowTemplates/WorkflowTemplateManager.tsx b/src/modules/manager/workflowTemplates/WorkflowTemplateManager.tsx index 3208968..ef45a27 100644 --- a/src/modules/manager/workflowTemplates/WorkflowTemplateManager.tsx +++ b/src/modules/manager/workflowTemplates/WorkflowTemplateManager.tsx @@ -90,7 +90,7 @@ const WotkflowTemplateManager: React.FC = () => { }; const onDelete = async (item?: ReadWorkflowTemplate) => { - const response = await workflowTemplatesService.deleteTemplateVersion( + const response = await workflowTemplatesService.deleteTemplate( item?.id, item?.guid, ); diff --git a/src/modules/manager/workflowTemplates/components/workflowGraphUtils.ts b/src/modules/manager/workflowTemplates/components/workflowGraphUtils.ts index 7c08c76..fb41cb3 100644 --- a/src/modules/manager/workflowTemplates/components/workflowGraphUtils.ts +++ b/src/modules/manager/workflowTemplates/components/workflowGraphUtils.ts @@ -1,25 +1,62 @@ import { TaskDefinition } from "../services/WorkflowTemplateService"; +function getOutcomeTargets(task: TaskDefinition): string[] { + const raw = task.config.outcomeActions as unknown; + + if (Array.isArray(raw)) { + return raw + .map((entry) => { + const value = entry as Partial<{ task: string | null }>; + return value.task; + }) + .filter((target): target is string => typeof target === "string"); + } + + if (raw && typeof raw === "object") { + return Object.values(raw as Record) + .filter((target): target is string => typeof target === "string") + .map((target) => target); + } + + return []; +} + +function buildOutcomeAdjacency( + tasks: TaskDefinition[], +): Map> { + const allIds = new Set(tasks.map((task) => task.config.guid as string)); + const adjacency = new Map>(); + + tasks.forEach((task) => { + const sourceId = task.config.guid as string; + const targets = new Set(); + + getOutcomeTargets(task).forEach((targetId) => { + if (targetId !== sourceId && allIds.has(targetId)) { + targets.add(targetId); + } + }); + + adjacency.set(sourceId, targets); + }); + + return adjacency; +} + export const getAllDescendants = ( guid: string, tasks: TaskDefinition[], ): Set => { const descendants = new Set(); + const adjacency = buildOutcomeAdjacency(tasks); const visit = (current: string) => { - for (const t of tasks) { - const preds = (t.config.predecessors as string[]) ?? []; - - // If t depends on current, it's a child - if ( - preds.includes(current) && - !descendants.has(t.config.guid as string) - ) { - const childGuid = t.config.guid as string; + (adjacency.get(current) ?? new Set()).forEach((childGuid) => { + if (!descendants.has(childGuid)) { descendants.add(childGuid); visit(childGuid); // recursively find grandchildren } - } + }); }; visit(guid); @@ -29,37 +66,120 @@ export const getAllDescendants = ( export const sortTasksTopologically = ( tasks: TaskDefinition[], ): TaskDefinition[] => { - // Build adjacency list: task -> its predecessors - const preds = new Map(); + // Build adjacency list from outcome transitions: task -> possible next tasks. + const adjacency = buildOutcomeAdjacency(tasks); const byId = new Map(); + const indexById = new Map(); + const indegree = new Map(); + const incoming = new Map>(); - tasks.forEach((t) => { - const guid = t.config.guid as string; - byId.set(guid, t); - preds.set(guid, (t.config.predecessors as string[]) ?? []); + tasks.forEach((task, index) => { + const guid = task.config.guid as string; + byId.set(guid, task); + indexById.set(guid, index); + indegree.set(guid, 0); + incoming.set(guid, new Set()); + }); + + adjacency.forEach((targets, sourceId) => { + targets.forEach((targetId) => { + indegree.set(targetId, (indegree.get(targetId) ?? 0) + 1); + incoming.get(targetId)?.add(sourceId); + }); + }); + + const insertByOriginalOrder = (queue: string[], guid: string) => { + const targetIndex = indexById.get(guid) ?? Number.MAX_SAFE_INTEGER; + let insertAt = queue.length; + + for (let i = 0; i < queue.length; i += 1) { + const queueIndex = indexById.get(queue[i]) ?? Number.MAX_SAFE_INTEGER; + if (targetIndex < queueIndex) { + insertAt = i; + break; + } + } + + queue.splice(insertAt, 0, guid); + }; + + const queue: string[] = []; + tasks.forEach((task) => { + const guid = task.config.guid as string; + if ((indegree.get(guid) ?? 0) === 0) { + insertByOriginalOrder(queue, guid); + } }); const result: TaskDefinition[] = []; - const visited = new Set(); - const temp = new Set(); // for cycle detection (should never trigger now) + while (queue.length > 0) { + const current = queue.shift() as string; + result.push(byId.get(current)!); - const visit = (guid: string) => { - if (visited.has(guid)) return; - if (temp.has(guid)) throw new Error("Cycle detected unexpectedly"); + (adjacency.get(current) ?? new Set()).forEach((targetId) => { + const nextInDegree = (indegree.get(targetId) ?? 0) - 1; + indegree.set(targetId, nextInDegree); + if (nextInDegree === 0) { + insertByOriginalOrder(queue, targetId); + } + }); + } - temp.add(guid); + // Cycle fallback: append remaining tasks in a stable, deterministic order. + if (result.length < tasks.length) { + const resultIds = new Set(result.map((task) => task.config.guid as string)); + const remainingIds = new Set( + tasks + .map((task) => task.config.guid as string) + .filter((guid) => !resultIds.has(guid)), + ); - for (const p of preds.get(guid) ?? []) { - visit(p); + while (remainingIds.size > 0) { + const candidates = Array.from(remainingIds); + + candidates.sort((aGuid, bGuid) => { + const aIncoming = incoming.get(aGuid) ?? new Set(); + const bIncoming = incoming.get(bGuid) ?? new Set(); + + let aFromResolved = 0; + let bFromResolved = 0; + let aFromRemaining = 0; + let bFromRemaining = 0; + + aIncoming.forEach((sourceId) => { + if (remainingIds.has(sourceId)) { + aFromRemaining += 1; + } else { + aFromResolved += 1; + } + }); + + bIncoming.forEach((sourceId) => { + if (remainingIds.has(sourceId)) { + bFromRemaining += 1; + } else { + bFromResolved += 1; + } + }); + + // Prefer tasks that are most reachable from already-ordered tasks. + if (aFromResolved !== bFromResolved) { + return bFromResolved - aFromResolved; + } + + // Then prefer tasks with fewer unresolved dependencies. + if (aFromRemaining !== bFromRemaining) { + return aFromRemaining - bFromRemaining; + } + + return (indexById.get(aGuid) ?? 0) - (indexById.get(bGuid) ?? 0); + }); + + const nextGuid = candidates[0]; + remainingIds.delete(nextGuid); + result.push(byId.get(nextGuid)!); } - - temp.delete(guid); - visited.add(guid); - result.push(byId.get(guid)!); - }; - - // Visit all tasks - tasks.forEach((t) => visit(t.config.guid as string)); + } return result; }; diff --git a/src/modules/manager/workflowTemplates/services/WorkflowTemplateService.ts b/src/modules/manager/workflowTemplates/services/WorkflowTemplateService.ts index c0ee0ee..8c88d14 100644 --- a/src/modules/manager/workflowTemplates/services/WorkflowTemplateService.ts +++ b/src/modules/manager/workflowTemplates/services/WorkflowTemplateService.ts @@ -75,6 +75,14 @@ export async function getTemplates( return response?.data; } +export async function deleteTemplate(id?: bigint, guid?: string): Promise { + const generalIdRef = MakeGeneralIdRef(id, guid); + + return await httpService.delete(apiEndpoint + "/template", { + data: generalIdRef, + }); +} + export async function getTemplateVersions( page: number, pageSize: number, @@ -162,6 +170,7 @@ export async function getTaskMetadata( const templateVersionsService = { getTemplates, + deleteTemplate, getTemplateVersions, getTemplateVersion, postTemplateVersion,