/** * Workflow integration for plugin. * * StreamingExecutionObserver is an ExecutionObserver implementation that emits * workflow events through the gRPC stream via req.emit(). * Optionally wraps an HttpExecutionObserver for API reporting. * * Import from '@nullplatform/plugin/workflow'. */ import type { ExecuteOutput } from "./types"; // The workflow packages are OPTIONAL peers: nothing here imports them // statically, so a package that never uses workflow chains typechecks and // bundles without them. The event names are the wire values of // @nullplatform/workflow-types' ExecutionEventType. const ExecutionEventType = { ExecutionPlan: "execution_plan", NodeStarted: "node_started", NodeCompleted: "node_completed", NodeFailed: "node_failed", NodeUpdated: "node_updated", CompensationStarted: "compensation_started", ExecutionUpdated: "execution_updated", } as const; /** The subset of @nullplatform/workflow-engine's ExecutionObserver this class speaks. */ export interface ExecutionObserver { createNodeExecution( executionId: string, data: Record ): Promise<{ id: string }>; updateNodeExecution( executionId: string, nodeExecutionId: string, data: Record ): Promise; updateExecution( executionId: string, data: Record ): Promise; } /** @nullplatform/workflow-engine's HttpExecutionObserver, loaded only when an apiUrl asks for it. */ async function httpObserver( apiUrl: string ): Promise { try { const mod = (await import("@nullplatform/workflow-engine" as string)) as { HttpExecutionObserver: new (url: string) => ExecutionObserver; }; return new mod.HttpExecutionObserver(apiUrl); } catch { return undefined; // the engine is not installed: stream only } } export class StreamingExecutionObserver implements ExecutionObserver { #emit: (output: ExecuteOutput) => void; #http?: Promise; #nodeKeys = new Map(); // nodeExecutionId → nodeKey constructor( emit: (output: ExecuteOutput) => void, options?: { apiUrl?: string } ) { this.#emit = emit; if (options?.apiUrl) { this.#http = httpObserver(options.apiUrl); } } /** Emit the workflow graph before execution starts. */ emitPlan(graph: { nodes: Array<{ key: string; spec: { name: string }; label?: string }>; edges: Array<{ sourceNodeKey: string; sourceOutput: string; targetNodeKey: string; targetInput: string; }>; }) { this.#emit({ data: { type: ExecutionEventType.ExecutionPlan, graph, }, }); } async createNodeExecution( executionId: string, data: { nodeKey: string; specName: string; status: string; phase: string; input?: unknown; startedAt?: string; compensationOf?: string; } ): Promise<{ id: string }> { this.#emit({ data: { type: data.compensationOf ? ExecutionEventType.CompensationStarted : ExecutionEventType.NodeStarted, executionId, nodeKey: data.nodeKey, specName: data.specName, status: data.status, phase: data.phase, input: data.input, compensationOf: data.compensationOf, }, }); const http = await this.#http; if (http) { const result = await http.createNodeExecution( executionId, data as unknown as Record ); this.#nodeKeys.set(result.id, data.nodeKey); return result; } const id = `local-${data.nodeKey}-${Date.now()}`; this.#nodeKeys.set(id, data.nodeKey); return { id }; } async updateNodeExecution( executionId: string, nodeExecutionId: string, data: Record ): Promise { const eventType = data.status === "completed" ? ExecutionEventType.NodeCompleted : data.status === "failed" ? ExecutionEventType.NodeFailed : ExecutionEventType.NodeUpdated; const nodeKey = this.#nodeKeys.get(nodeExecutionId) ?? ""; this.#emit({ data: { type: eventType, executionId, nodeExecutionId, nodeKey, ...data }, }); const http = await this.#http; if (http) return http.updateNodeExecution(executionId, nodeExecutionId, data); } async updateExecution( executionId: string, data: Record ): Promise { this.#emit({ data: { type: ExecutionEventType.ExecutionUpdated, executionId, ...data }, }); const http = await this.#http; if (http) return http.updateExecution(executionId, data); } }