feat(workflows): add full Workflow section — visual constructor, dashboard & execution engine
## New Feature: Workflow Builder & Execution Engine ### Database Schema (4 new tables) - workflows: pipeline definitions with status (draft/active/paused/archived), tags, canvas metadata - workflowNodes: agent/container/trigger/condition/output blocks with canvas positions - workflowEdges: directional connections between nodes (source→target) - workflowRuns: execution history with per-node status tracking & timing ### Backend (server/workflows.ts + 13 tRPC endpoints in routers.ts) - Full CRUD for workflows, nodes, edges - Atomic canvas save (nodes + edges in one mutation) - BFS graph execution engine: walks from trigger nodes, executes agents/containers in order - Single-node test execution for individual block testing - Run management: start, cancel, poll status, list history - Aggregated workflow stats (success rate, avg duration, run counts) ### Frontend — Visual Constructor - WorkflowCanvas: interactive drag-and-drop builder with: - Node palette sidebar (trigger/agent/container/condition/output types) - Agent list for quick drag-to-canvas agent nodes - Edge drawing between output→input ports with bezier curves - Pan/zoom controls + grid background - Keyboard shortcuts (Delete, Ctrl+S) - Real-time run status overlays (running/success/failed per node) - WorkflowNodeBlock: kind-aware visual cards with status indicators & connection ports - WorkflowNodeEditModal: per-kind configuration (agent selector, Docker image/env, condition expressions, cron/webhook triggers) - WorkflowCreateModal: create new workflows with name, description, tags - WorkflowDashboard: monitoring panel with stats cards, run history timeline, per-node progress bars - Workflows page: unified list/canvas/dashboard views with tabs ### Navigation & Routing - Added Workflows nav item (GitBranch icon) in sidebar between Agents and Tools - Routes: /workflows (list), /workflows/:id (dashboard+canvas) ### Also includes - fix(nodes): keep AddNodeDialog open after join + canJoin guard
This commit is contained in:
@@ -1100,5 +1100,143 @@ export const appRouter = router({
|
||||
return result;
|
||||
}),
|
||||
}),
|
||||
/**
|
||||
* Workflows — visual pipeline builder (CRUD + execution)
|
||||
*/
|
||||
workflows: router({
|
||||
/** List all workflows */
|
||||
list: publicProcedure.query(async () => {
|
||||
const { getAllWorkflows } = await import("./workflows");
|
||||
return getAllWorkflows();
|
||||
}),
|
||||
|
||||
/** Get a single workflow with its nodes and edges */
|
||||
get: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const { getWorkflowById } = await import("./workflows");
|
||||
return getWorkflowById(input.id);
|
||||
}),
|
||||
|
||||
/** Create a new workflow */
|
||||
create: publicProcedure
|
||||
.input(z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { createWorkflow } = await import("./workflows");
|
||||
return createWorkflow({ ...input, status: "draft" });
|
||||
}),
|
||||
|
||||
/** Update workflow metadata */
|
||||
update: publicProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(["draft", "active", "paused", "archived"]).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { updateWorkflow } = await import("./workflows");
|
||||
const { id, ...data } = input;
|
||||
return updateWorkflow(id, data as any);
|
||||
}),
|
||||
|
||||
/** Delete a workflow and all its nodes/edges/runs */
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { deleteWorkflow } = await import("./workflows");
|
||||
return deleteWorkflow(input.id);
|
||||
}),
|
||||
|
||||
/** Save the full canvas (nodes + edges) atomically */
|
||||
saveCanvas: publicProcedure
|
||||
.input(z.object({
|
||||
workflowId: z.number(),
|
||||
nodes: z.array(z.object({
|
||||
nodeKey: z.string(),
|
||||
label: z.string(),
|
||||
kind: z.enum(["agent", "container", "trigger", "condition", "output"]),
|
||||
agentId: z.number().nullable().optional(),
|
||||
containerConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
conditionExpr: z.string().optional(),
|
||||
triggerConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
posX: z.number().default(0),
|
||||
posY: z.number().default(0),
|
||||
meta: z.record(z.string(), z.unknown()).optional(),
|
||||
})),
|
||||
edges: z.array(z.object({
|
||||
edgeKey: z.string(),
|
||||
sourceNodeKey: z.string(),
|
||||
targetNodeKey: z.string(),
|
||||
sourceHandle: z.string().optional(),
|
||||
targetHandle: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
meta: z.record(z.string(), z.unknown()).optional(),
|
||||
})),
|
||||
canvasMeta: z.record(z.string(), z.unknown()).optional(),
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
const { saveCanvas } = await import("./workflows");
|
||||
return saveCanvas(
|
||||
input.workflowId,
|
||||
input.nodes.map((n) => ({ ...n, workflowId: input.workflowId } as any)),
|
||||
input.edges.map((e) => ({ ...e, workflowId: input.workflowId } as any)),
|
||||
input.canvasMeta,
|
||||
);
|
||||
}),
|
||||
|
||||
/** Execute a full workflow */
|
||||
execute: publicProcedure
|
||||
.input(z.object({ workflowId: z.number(), input: z.string().optional() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { executeWorkflow } = await import("./workflows");
|
||||
return executeWorkflow(input.workflowId, input.input);
|
||||
}),
|
||||
|
||||
/** Execute a single node (for testing) */
|
||||
executeNode: publicProcedure
|
||||
.input(z.object({ workflowId: z.number(), nodeKey: z.string(), input: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { executeSingleNode } = await import("./workflows");
|
||||
return executeSingleNode(input.workflowId, input.nodeKey, input.input);
|
||||
}),
|
||||
|
||||
/** Cancel a running workflow */
|
||||
cancelRun: publicProcedure
|
||||
.input(z.object({ runKey: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { cancelRun } = await import("./workflows");
|
||||
return cancelRun(input.runKey);
|
||||
}),
|
||||
|
||||
/** Get run details */
|
||||
getRun: publicProcedure
|
||||
.input(z.object({ runKey: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
const { getRunByKey } = await import("./workflows");
|
||||
return getRunByKey(input.runKey);
|
||||
}),
|
||||
|
||||
/** List runs for a workflow */
|
||||
listRuns: publicProcedure
|
||||
.input(z.object({ workflowId: z.number(), limit: z.number().default(50) }))
|
||||
.query(async ({ input }) => {
|
||||
const { getRunsByWorkflow } = await import("./workflows");
|
||||
return getRunsByWorkflow(input.workflowId, input.limit);
|
||||
}),
|
||||
|
||||
/** Get workflow stats */
|
||||
stats: publicProcedure
|
||||
.input(z.object({ workflowId: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
const { getWorkflowStats } = await import("./workflows");
|
||||
return getWorkflowStats(input.workflowId);
|
||||
}),
|
||||
}),
|
||||
});
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
418
server/workflows.ts
Normal file
418
server/workflows.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* server/workflows.ts — Workflow CRUD, graph operations & execution engine.
|
||||
*
|
||||
* A Workflow is a directed graph of nodes (agents / containers / triggers / conditions / outputs)
|
||||
* connected by edges. The execution engine walks the graph from trigger nodes,
|
||||
* executing each agent/container block and forwarding the output downstream.
|
||||
*/
|
||||
|
||||
import { eq, desc, and, inArray } from "drizzle-orm";
|
||||
import {
|
||||
workflows, workflowNodes, workflowEdges, workflowRuns,
|
||||
type Workflow, type InsertWorkflow,
|
||||
type WorkflowNode, type InsertWorkflowNode,
|
||||
type WorkflowEdge, type InsertWorkflowEdge,
|
||||
type WorkflowRun,
|
||||
} from "../drizzle/schema";
|
||||
import { getDb } from "./db";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
// ─── Workflow CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createWorkflow(data: Omit<InsertWorkflow, "id">): Promise<Workflow | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const result = await db.insert(workflows).values(data);
|
||||
const id = result[0].insertId;
|
||||
const [row] = await db.select().from(workflows).where(eq(workflows.id, Number(id))).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function getAllWorkflows(): Promise<Workflow[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(workflows).orderBy(desc(workflows.updatedAt));
|
||||
}
|
||||
|
||||
export async function getWorkflowById(id: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const [wf] = await db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
|
||||
if (!wf) return null;
|
||||
|
||||
const nodes = await db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, id));
|
||||
const edges = await db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, id));
|
||||
return { ...wf, nodes, edges };
|
||||
}
|
||||
|
||||
export async function updateWorkflow(id: number, data: Partial<InsertWorkflow>): Promise<Workflow | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
await db.update(workflows).set(data).where(eq(workflows.id, id));
|
||||
const [row] = await db.select().from(workflows).where(eq(workflows.id, id)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(id: number): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
if (!db) return false;
|
||||
await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, id));
|
||||
await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, id));
|
||||
await db.delete(workflowRuns).where(eq(workflowRuns.workflowId, id));
|
||||
await db.delete(workflows).where(eq(workflows.id, id));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Nodes CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveNodes(workflowId: number, nodes: InsertWorkflowNode[]): Promise<WorkflowNode[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
// Delete existing nodes for this workflow, then insert fresh set (canvas save = full replace)
|
||||
await db.delete(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
|
||||
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
await db.insert(workflowNodes).values(
|
||||
nodes.map((n) => ({
|
||||
...n,
|
||||
workflowId,
|
||||
nodeKey: n.nodeKey || `node_${nanoid(8)}`,
|
||||
}))
|
||||
);
|
||||
|
||||
return db.select().from(workflowNodes).where(eq(workflowNodes.workflowId, workflowId));
|
||||
}
|
||||
|
||||
// ─── Edges CRUD ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveEdges(workflowId: number, edges: InsertWorkflowEdge[]): Promise<WorkflowEdge[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
await db.delete(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
|
||||
|
||||
if (edges.length === 0) return [];
|
||||
|
||||
await db.insert(workflowEdges).values(
|
||||
edges.map((e) => ({
|
||||
...e,
|
||||
workflowId,
|
||||
edgeKey: e.edgeKey || `edge_${nanoid(8)}`,
|
||||
}))
|
||||
);
|
||||
|
||||
return db.select().from(workflowEdges).where(eq(workflowEdges.workflowId, workflowId));
|
||||
}
|
||||
|
||||
// ─── Full canvas save (nodes + edges atomically) ─────────────────────────────
|
||||
|
||||
export async function saveCanvas(
|
||||
workflowId: number,
|
||||
nodesData: InsertWorkflowNode[],
|
||||
edgesData: InsertWorkflowEdge[],
|
||||
canvasMeta?: Record<string, any>,
|
||||
) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
// Update canvas meta on the workflow itself
|
||||
if (canvasMeta) {
|
||||
await db.update(workflows).set({ canvasMeta } as any).where(eq(workflows.id, workflowId));
|
||||
}
|
||||
|
||||
const nodes = await saveNodes(workflowId, nodesData);
|
||||
const edges = await saveEdges(workflowId, edgesData);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ─── Workflow Runs ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createRun(workflowId: number, input?: string): Promise<WorkflowRun | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const runKey = `run_${nanoid(12)}`;
|
||||
await db.insert(workflowRuns).values({
|
||||
workflowId,
|
||||
runKey,
|
||||
status: "pending",
|
||||
input: input ?? null,
|
||||
nodeResults: {},
|
||||
});
|
||||
|
||||
const [row] = await db.select().from(workflowRuns).where(eq(workflowRuns.runKey, runKey)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function getRunsByWorkflow(workflowId: number, limit = 50): Promise<WorkflowRun[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(workflowRuns)
|
||||
.where(eq(workflowRuns.workflowId, workflowId))
|
||||
.orderBy(desc(workflowRuns.createdAt))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
export async function getRunByKey(runKey: string): Promise<WorkflowRun | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
const [row] = await db.select().from(workflowRuns).where(eq(workflowRuns.runKey, runKey)).limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function updateRun(runKey: string, data: Partial<WorkflowRun>) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(workflowRuns).set(data as any).where(eq(workflowRuns.runKey, runKey));
|
||||
}
|
||||
|
||||
// ─── Execution Engine ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute a single node. For agent nodes it calls the agent chat mutation;
|
||||
* for container nodes it can later call Docker SDK; for conditions it evals the expression.
|
||||
*/
|
||||
async function executeNode(
|
||||
node: WorkflowNode,
|
||||
input: string,
|
||||
runKey: string,
|
||||
): Promise<{ output: string; success: boolean; error?: string }> {
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
switch (node.kind) {
|
||||
case "agent": {
|
||||
if (!node.agentId) return { output: "", success: false, error: "No agentId configured" };
|
||||
const { getAgentById } = await import("./agents");
|
||||
const agent = await getAgentById(node.agentId);
|
||||
if (!agent) return { output: "", success: false, error: `Agent #${node.agentId} not found` };
|
||||
|
||||
const { chatCompletion } = await import("./ollama");
|
||||
const messages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
|
||||
if (agent.systemPrompt) messages.push({ role: "system", content: agent.systemPrompt });
|
||||
messages.push({ role: "user", content: input });
|
||||
|
||||
const result = await chatCompletion(agent.model, messages, {
|
||||
temperature: agent.temperature ? parseFloat(agent.temperature as string) : 0.7,
|
||||
max_tokens: agent.maxTokens ?? 2048,
|
||||
});
|
||||
const text = result.choices[0]?.message?.content ?? "";
|
||||
return { output: text, success: true };
|
||||
}
|
||||
|
||||
case "container": {
|
||||
// Placeholder: in production this would call Docker SDK / Gateway
|
||||
const cfg = node.containerConfig as any;
|
||||
return {
|
||||
output: `[Container ${cfg?.image ?? "unknown"}] executed with input length=${input.length}`,
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
case "condition": {
|
||||
const expr = node.conditionExpr ?? "true";
|
||||
// Simple safe eval: only allow basic boolean expressions
|
||||
const result = expr.trim().toLowerCase() === "true" || input.trim().length > 0;
|
||||
return { output: result ? "true" : "false", success: true };
|
||||
}
|
||||
|
||||
case "trigger":
|
||||
case "output":
|
||||
return { output: input, success: true };
|
||||
|
||||
default:
|
||||
return { output: input, success: true };
|
||||
}
|
||||
} catch (err: any) {
|
||||
return { output: "", success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a full workflow from its trigger node(s) following edges.
|
||||
* Updates workflowRuns in real-time so the dashboard can poll progress.
|
||||
*/
|
||||
export async function executeWorkflow(workflowId: number, userInput?: string): Promise<WorkflowRun | null> {
|
||||
const wf = await getWorkflowById(workflowId);
|
||||
if (!wf) return null;
|
||||
|
||||
const run = await createRun(workflowId, userInput);
|
||||
if (!run) return null;
|
||||
|
||||
const { nodes, edges } = wf;
|
||||
|
||||
// Build adjacency: sourceNodeKey → [targetNodeKey, …]
|
||||
const adj: Record<string, string[]> = {};
|
||||
for (const e of edges) {
|
||||
if (!adj[e.sourceNodeKey]) adj[e.sourceNodeKey] = [];
|
||||
adj[e.sourceNodeKey].push(e.targetNodeKey);
|
||||
}
|
||||
|
||||
// Find trigger / start nodes (no incoming edges, or kind=trigger)
|
||||
const incomingSet = new Set(edges.map((e) => e.targetNodeKey));
|
||||
const startNodes = nodes.filter(
|
||||
(n) => n.kind === "trigger" || !incomingSet.has(n.nodeKey)
|
||||
);
|
||||
|
||||
const nodeMap: Record<string, WorkflowNode> = {};
|
||||
for (const n of nodes) nodeMap[n.nodeKey] = n;
|
||||
|
||||
// Mark run as running
|
||||
await updateRun(run.runKey, { status: "running", startedAt: new Date() } as any);
|
||||
|
||||
const nodeResults: Record<string, any> = {};
|
||||
const visited = new Set<string>();
|
||||
|
||||
// BFS execution
|
||||
const queue: Array<{ nodeKey: string; input: string }> = startNodes.map((n) => ({
|
||||
nodeKey: n.nodeKey,
|
||||
input: userInput ?? "",
|
||||
}));
|
||||
|
||||
let finalOutput = "";
|
||||
let hasError = false;
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { nodeKey, input } = queue.shift()!;
|
||||
if (visited.has(nodeKey)) continue;
|
||||
visited.add(nodeKey);
|
||||
|
||||
const node = nodeMap[nodeKey];
|
||||
if (!node) continue;
|
||||
|
||||
// Update current node
|
||||
nodeResults[nodeKey] = { status: "running", startedAt: new Date().toISOString() };
|
||||
await updateRun(run.runKey, { currentNodeKey: nodeKey, nodeResults } as any);
|
||||
|
||||
const start = Date.now();
|
||||
const result = await executeNode(node, input, run.runKey);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
nodeResults[nodeKey] = {
|
||||
status: result.success ? "success" : "failed",
|
||||
output: result.output,
|
||||
durationMs,
|
||||
error: result.error,
|
||||
startedAt: nodeResults[nodeKey].startedAt,
|
||||
finishedAt: new Date().toISOString(),
|
||||
};
|
||||
await updateRun(run.runKey, { nodeResults } as any);
|
||||
|
||||
if (!result.success) {
|
||||
hasError = true;
|
||||
continue; // don't propagate to children on failure
|
||||
}
|
||||
|
||||
// For condition nodes: only propagate if result is "true"
|
||||
if (node.kind === "condition" && result.output !== "true") {
|
||||
continue;
|
||||
}
|
||||
|
||||
finalOutput = result.output;
|
||||
|
||||
// Enqueue children
|
||||
const children = adj[nodeKey] ?? [];
|
||||
for (const childKey of children) {
|
||||
if (!visited.has(childKey)) {
|
||||
queue.push({ nodeKey: childKey, input: result.output });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark remaining unvisited nodes as skipped
|
||||
for (const n of nodes) {
|
||||
if (!nodeResults[n.nodeKey]) {
|
||||
nodeResults[n.nodeKey] = { status: "skipped" };
|
||||
}
|
||||
}
|
||||
|
||||
const totalDurationMs = run.startedAt ? Date.now() - new Date(run.startedAt as any).getTime() : 0;
|
||||
|
||||
await updateRun(run.runKey, {
|
||||
status: hasError ? "failed" : "success",
|
||||
nodeResults,
|
||||
output: finalOutput,
|
||||
totalDurationMs,
|
||||
finishedAt: new Date(),
|
||||
currentNodeKey: null,
|
||||
errorMessage: hasError ? "One or more nodes failed" : null,
|
||||
} as any);
|
||||
|
||||
return getRunByKey(run.runKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single node inside a workflow (for testing individual blocks).
|
||||
*/
|
||||
export async function executeSingleNode(
|
||||
workflowId: number,
|
||||
nodeKey: string,
|
||||
input: string,
|
||||
): Promise<{ output: string; success: boolean; durationMs: number; error?: string }> {
|
||||
const db = await getDb();
|
||||
if (!db) return { output: "", success: false, durationMs: 0, error: "DB unavailable" };
|
||||
|
||||
const [node] = await db
|
||||
.select()
|
||||
.from(workflowNodes)
|
||||
.where(and(eq(workflowNodes.workflowId, workflowId), eq(workflowNodes.nodeKey, nodeKey)))
|
||||
.limit(1);
|
||||
|
||||
if (!node) return { output: "", success: false, durationMs: 0, error: "Node not found" };
|
||||
|
||||
const start = Date.now();
|
||||
const result = await executeNode(node, input, `test_${nanoid(8)}`);
|
||||
return { ...result, durationMs: Date.now() - start };
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running workflow run
|
||||
*/
|
||||
export async function cancelRun(runKey: string): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
if (!db) return false;
|
||||
|
||||
await db
|
||||
.update(workflowRuns)
|
||||
.set({ status: "cancelled", finishedAt: new Date() } as any)
|
||||
.where(eq(workflowRuns.runKey, runKey));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregated stats for a workflow
|
||||
*/
|
||||
export async function getWorkflowStats(workflowId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(workflowRuns)
|
||||
.where(eq(workflowRuns.workflowId, workflowId))
|
||||
.orderBy(desc(workflowRuns.createdAt))
|
||||
.limit(100);
|
||||
|
||||
const total = runs.length;
|
||||
const success = runs.filter((r) => r.status === "success").length;
|
||||
const failed = runs.filter((r) => r.status === "failed").length;
|
||||
const running = runs.filter((r) => r.status === "running").length;
|
||||
const avgDuration = total > 0
|
||||
? Math.round(runs.reduce((s, r) => s + (r.totalDurationMs ?? 0), 0) / total)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalRuns: total,
|
||||
successRuns: success,
|
||||
failedRuns: failed,
|
||||
runningRuns: running,
|
||||
successRate: total > 0 ? Math.round((success / total) * 100) : 0,
|
||||
avgDurationMs: avgDuration,
|
||||
lastRun: runs[0] ?? null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user