diff --git a/.changeset/graph-step-traversal.md b/.changeset/graph-step-traversal.md new file mode 100644 index 000000000..f67dc141a --- /dev/null +++ b/.changeset/graph-step-traversal.md @@ -0,0 +1,6 @@ +--- +'@workflow/swc-plugin': patch +--- + +Fix graph mode traversal so it walks workflow bodies with a DFS pass, capturing direct calls, callbacks, and nested workflow references in the emitted graph manifest. + diff --git a/.changeset/migrate-nuqs-url-state.md b/.changeset/migrate-nuqs-url-state.md new file mode 100644 index 000000000..b068ea791 --- /dev/null +++ b/.changeset/migrate-nuqs-url-state.md @@ -0,0 +1,6 @@ +--- +"@workflow/web": patch +--- + +Migrate to nuqs for URL state management. Replaces custom URL parameter hooks with the nuqs library for better type-safety and simpler state management. + diff --git a/packages/builders/src/apply-swc-transform.ts b/packages/builders/src/apply-swc-transform.ts index c4fc76616..cec2d89cb 100644 --- a/packages/builders/src/apply-swc-transform.ts +++ b/packages/builders/src/apply-swc-transform.ts @@ -20,10 +20,38 @@ export type WorkflowManifest = { }; }; +export type GraphManifest = { + version: string; + workflows: { + [workflowName: string]: { + workflowId: string; + workflowName: string; + filePath: string; + nodes: Array<{ + id: string; + type: string; + position: { x: number; y: number }; + data: { + label: string; + nodeKind: string; + stepId?: string; + line: number; + }; + }>; + edges: Array<{ + id: string; + source: string; + target: string; + type: string; + }>; + }; + }; +}; + export async function applySwcTransform( filename: string, source: string, - mode: 'workflow' | 'step' | 'client' | false, + mode: 'workflow' | 'step' | 'client' | 'graph' | false, jscConfig?: { paths?: Record; // this must be absolute path @@ -32,6 +60,7 @@ export async function applySwcTransform( ): Promise<{ code: string; workflowManifest: WorkflowManifest; + graphManifest?: GraphManifest; }> { // Determine if this is a TypeScript file const isTypeScript = filename.endsWith('.ts') || filename.endsWith('.tsx'); @@ -65,12 +94,24 @@ export async function applySwcTransform( /\/\*\*__internal_workflows({.*?})\*\//s ); - const parsedWorkflows = JSON.parse( - workflowCommentMatch?.[1] || '{}' - ) as WorkflowManifest; + const metadata = JSON.parse(workflowCommentMatch?.[1] || '{}'); + + const parsedWorkflows = { + steps: metadata.steps, + workflows: metadata.workflows, + } as WorkflowManifest; + + // Extract graph manifest from separate comment + const graphCommentMatch = result.code.match( + /\/\*\*__workflow_graph({.*?})\*\//s + ); + const graphManifest = graphCommentMatch?.[1] + ? (JSON.parse(graphCommentMatch[1]) as GraphManifest) + : undefined; return { code: result.code, workflowManifest: parsedWorkflows || {}, + graphManifest, }; } diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 4319251d2..e20f08c3d 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -7,7 +7,7 @@ import enhancedResolveOriginal from 'enhanced-resolve'; import * as esbuild from 'esbuild'; import { findUp } from 'find-up'; import { glob } from 'tinyglobby'; -import type { WorkflowManifest } from './apply-swc-transform.js'; +import type { GraphManifest, WorkflowManifest } from './apply-swc-transform.js'; import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.js'; import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js'; import { createSwcPlugin } from './swc-esbuild-plugin.js'; @@ -838,4 +838,91 @@ export const OPTIONS = handler;`; // We're intentionally silently ignoring this error - creating .gitignore isn't critical } } + + /** + * Creates a graph manifest JSON file by running the SWC plugin in 'graph' mode. + * The manifest contains React Flow-compatible graph data for visualizing workflows. + */ + protected async createGraphManifest({ + inputFiles, + outfile, + tsBaseUrl, + tsPaths, + }: { + inputFiles: string[]; + outfile: string; + tsBaseUrl?: string; + tsPaths?: Record; + }): Promise { + const graphBuildStart = Date.now(); + console.log('Creating workflow graph manifest...'); + + const { discoveredWorkflows: workflowFiles } = await this.discoverEntries( + inputFiles, + dirname(outfile) + ); + + if (workflowFiles.length === 0) { + console.log('No workflow files found, skipping graph generation'); + return; + } + + // Import applySwcTransform dynamically + const { applySwcTransform } = await import('./apply-swc-transform.js'); + + // Aggregate all graph data from all workflow files + const combinedGraphManifest: GraphManifest = { + version: '1.0.0', + workflows: {}, + }; + + for (const workflowFile of workflowFiles) { + try { + const source = await readFile(workflowFile, 'utf-8'); + const normalizedWorkingDir = this.config.workingDir.replace(/\\/g, '/'); + const normalizedFile = workflowFile.replace(/\\/g, '/'); + let relativePath = relative( + normalizedWorkingDir, + normalizedFile + ).replace(/\\/g, '/'); + if (!relativePath.startsWith('.')) { + relativePath = `./${relativePath}`; + } + + const { graphManifest } = await applySwcTransform( + relativePath, + source, + 'graph', + { + paths: tsPaths, + baseUrl: tsBaseUrl, + } + ); + + if (graphManifest && graphManifest.workflows) { + // Merge the workflows from this file into the combined manifest + Object.assign( + combinedGraphManifest.workflows, + graphManifest.workflows + ); + } + } catch (error) { + console.warn( + `Failed to extract graph from ${workflowFile}:`, + error instanceof Error ? error.message : String(error) + ); + } + } + + // Write the combined graph manifest + await this.ensureDirectory(outfile); + await writeFile(outfile, JSON.stringify(combinedGraphManifest, null, 2)); + + console.log( + `Created graph manifest with ${ + Object.keys(combinedGraphManifest.workflows).length + } workflow(s)`, + `${Date.now() - graphBuildStart}ms` + ); + } } diff --git a/packages/builders/src/standalone.ts b/packages/builders/src/standalone.ts index c4ef2c936..b62396e35 100644 --- a/packages/builders/src/standalone.ts +++ b/packages/builders/src/standalone.ts @@ -13,6 +13,7 @@ export class StandaloneBuilder extends BaseBuilder { await this.buildStepsBundle(options); await this.buildWorkflowsBundle(options); await this.buildWebhookFunction(); + await this.buildGraphManifest(options); await this.createClientLibrary(); } @@ -76,4 +77,24 @@ export class StandaloneBuilder extends BaseBuilder { outfile: webhookBundlePath, }); } + + private async buildGraphManifest({ + inputFiles, + tsPaths, + tsBaseUrl, + }: { + inputFiles: string[]; + tsBaseUrl?: string; + tsPaths?: Record; + }): Promise { + const graphManifestPath = this.resolvePath('.swc/graph-manifest.json'); + await this.ensureDirectory(graphManifestPath); + + await this.createGraphManifest({ + inputFiles, + outfile: graphManifestPath, + tsBaseUrl, + tsPaths, + }); + } } diff --git a/packages/next/src/builder.ts b/packages/next/src/builder.ts index 28a52e681..8ade4a666 100644 --- a/packages/next/src/builder.ts +++ b/packages/next/src/builder.ts @@ -46,6 +46,20 @@ export async function getNextBuilder() { const stepsBuildContext = await this.buildStepsFunction(options); const workflowsBundle = await this.buildWorkflowsFunction(options); await this.buildWebhookRoute({ workflowGeneratedDir }); + + // Write graph manifest to workflow data directory + const workflowDataDir = join( + this.config.workingDir, + '.next/workflow-data' + ); + await mkdir(workflowDataDir, { recursive: true }); + await this.createGraphManifest({ + inputFiles: options.inputFiles, + outfile: join(workflowDataDir, 'graph-manifest.json'), + tsBaseUrl: options.tsBaseUrl, + tsPaths: options.tsPaths, + }); + await this.writeFunctionsConfig(outputDir); if (this.config.watch) { @@ -166,6 +180,23 @@ export async function getNextBuilder() { ); } workflowsCtx = newWorkflowsCtx; + + // Rebuild graph manifest to workflow data directory + try { + const workflowDataDir = join( + this.config.workingDir, + '.next/workflow-data' + ); + await mkdir(workflowDataDir, { recursive: true }); + await this.createGraphManifest({ + inputFiles: options.inputFiles, + outfile: join(workflowDataDir, 'graph-manifest.json'), + tsBaseUrl: options.tsBaseUrl, + tsPaths: options.tsPaths, + }); + } catch (error) { + console.error('Failed to rebuild graph manifest:', error); + } }; const logBuildMessages = ( @@ -220,6 +251,23 @@ export async function getNextBuilder() { 'Rebuilt workflow bundle', `${Date.now() - rebuiltWorkflowStart}ms` ); + + // Rebuild graph manifest to workflow data directory + try { + const workflowDataDir = join( + this.config.workingDir, + '.next/workflow-data' + ); + await mkdir(workflowDataDir, { recursive: true }); + await this.createGraphManifest({ + inputFiles: options.inputFiles, + outfile: join(workflowDataDir, 'graph-manifest.json'), + tsBaseUrl: options.tsBaseUrl, + tsPaths: options.tsPaths, + }); + } catch (error) { + console.error('Failed to rebuild graph manifest:', error); + } }; const isWatchableFile = (path: string) => diff --git a/packages/swc-plugin-workflow/transform/src/graph.rs b/packages/swc-plugin-workflow/transform/src/graph.rs new file mode 100644 index 000000000..69b75e5b4 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/src/graph.rs @@ -0,0 +1,220 @@ +use serde::Serialize; +use std::collections::HashMap; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowGraphManifest { + pub version: String, + pub workflows: HashMap, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct WorkflowGraph { + pub workflow_id: String, + pub workflow_name: String, + pub file_path: String, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GraphNode { + pub id: String, + #[serde(rename = "type")] + pub node_type: String, + pub position: Position, + pub data: NodeData, +} + +#[derive(Debug, Serialize, Clone)] +pub struct Position { + pub x: f64, + pub y: f64, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NodeData { + pub label: String, + pub node_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub step_id: Option, + pub line: usize, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GraphEdge { + pub id: String, + pub source: String, + pub target: String, + #[serde(rename = "type")] + pub edge_type: String, +} + +#[derive(Debug)] +pub struct GraphBuilder { + graphs: HashMap, + current_workflow: Option, + current_y: f64, + node_count: usize, + prev_node_id: Option, +} + +impl GraphBuilder { + pub fn new() -> Self { + Self { + graphs: HashMap::new(), + current_workflow: None, + current_y: 0.0, + node_count: 0, + prev_node_id: None, + } + } + + pub fn start_workflow(&mut self, name: &str, file_path: &str, workflow_id: &str) { + let graph = WorkflowGraph { + workflow_id: workflow_id.to_string(), + workflow_name: name.to_string(), + file_path: file_path.to_string(), + nodes: vec![], + edges: vec![], + }; + + self.graphs.insert(name.to_string(), graph); + self.current_workflow = Some(name.to_string()); + self.current_y = 0.0; + self.node_count = 0; + self.prev_node_id = None; + + // Add start node + self.add_node( + "start", + "workflowStart", + &format!("Start: {}", name), + "workflow_start", + None, + 0, + ); + } + + pub fn add_step_node(&mut self, step_name: &str, step_id: &str, line: usize) { + let node_id = format!("node_{}", self.node_count); + self.add_node( + &node_id, + "step", + step_name, + "step", + Some(step_id.to_string()), + line, + ); + } + + pub fn add_workflow_node(&mut self, workflow_name: &str, workflow_id: &str, line: usize) { + let node_id = format!("node_{}", self.node_count); + self.add_node( + &node_id, + "workflowCall", + workflow_name, + "workflow", + Some(workflow_id.to_string()), + line, + ); + } + + fn add_node( + &mut self, + id: &str, + node_type: &str, + label: &str, + node_kind: &str, + step_id: Option, + line: usize, + ) { + if let Some(workflow_name) = &self.current_workflow { + if let Some(graph) = self.graphs.get_mut(workflow_name) { + let node = GraphNode { + id: id.to_string(), + node_type: node_type.to_string(), + position: Position { + x: 250.0, + y: self.current_y, + }, + data: NodeData { + label: label.to_string(), + node_kind: node_kind.to_string(), + step_id, + line, + }, + }; + + // Add edge from previous node + if let Some(prev_id) = &self.prev_node_id { + let edge = GraphEdge { + id: format!("e_{}_{}", prev_id, id), + source: prev_id.clone(), + target: id.to_string(), + edge_type: "default".to_string(), + }; + graph.edges.push(edge); + } + + graph.nodes.push(node); + self.prev_node_id = Some(id.to_string()); + self.current_y += 100.0; + self.node_count += 1; + } + } + } + + pub fn finish_workflow(&mut self) { + if let Some(workflow_name) = &self.current_workflow { + if let Some(graph) = self.graphs.get_mut(workflow_name) { + // Add end node + let end_node = GraphNode { + id: "end".to_string(), + node_type: "workflowEnd".to_string(), + position: Position { + x: 250.0, + y: self.current_y, + }, + data: NodeData { + label: "Return".to_string(), + node_kind: "workflow_end".to_string(), + step_id: None, + line: 0, + }, + }; + + // Add edge from last node to end + if let Some(prev_id) = &self.prev_node_id { + let edge = GraphEdge { + id: format!("e_{}_end", prev_id), + source: prev_id.clone(), + target: "end".to_string(), + edge_type: "default".to_string(), + }; + graph.edges.push(edge); + } + + graph.nodes.push(end_node); + } + } + + self.current_workflow = None; + self.prev_node_id = None; + } + + pub fn to_manifest(self) -> WorkflowGraphManifest { + WorkflowGraphManifest { + version: "1.0.0".to_string(), + workflows: self.graphs, + } + } + + pub fn has_workflows(&self) -> bool { + !self.graphs.is_empty() + } +} diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs index ac490f47a..b6a0a025d 100644 --- a/packages/swc-plugin-workflow/transform/src/lib.rs +++ b/packages/swc-plugin-workflow/transform/src/lib.rs @@ -1,12 +1,13 @@ +mod graph; mod naming; use serde::Deserialize; -use std::collections::{HashSet, HashMap}; +use std::collections::{HashMap, HashSet}; use swc_core::{ common::{DUMMY_SP, SyntaxContext, errors::HANDLER}, ecma::{ ast::*, - visit::{VisitMut, VisitMutWith, noop_visit_mut_type}, + visit::{Visit, VisitMut, VisitMutWith, VisitWith, noop_visit_mut_type}, }, }; @@ -144,6 +145,7 @@ pub enum TransformMode { Step, Workflow, Client, + Graph, } #[derive(Debug)] @@ -196,11 +198,163 @@ pub struct StepTransform { // Track object properties that need to be converted to initializer calls in workflow mode // (parent_var_name, prop_name, step_id) object_property_workflow_conversions: Vec<(String, String, String)>, + // Graph builder for graph mode + graph_builder: Option, + pending_graph_workflows: Vec<(String, String, Function)>, // Current context: variable name being processed when visiting object properties #[allow(dead_code)] current_var_context: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GraphCallKind { + Step, + Workflow, +} + +#[derive(Debug, Clone)] +struct GraphCallEvent { + fn_name: String, + span: swc_core::common::Span, + kind: GraphCallKind, +} + +#[derive(Debug, Clone)] +struct GraphNodeRef { + name: String, + kind: GraphCallKind, +} + +struct GraphUsageCollector<'a> { + step_function_names: &'a HashSet, + workflow_function_names: &'a HashSet, + aliases: HashMap, + calls: Vec, +} + +impl<'a> GraphUsageCollector<'a> { + fn new( + step_function_names: &'a HashSet, + workflow_function_names: &'a HashSet, + ) -> Self { + Self { + step_function_names, + workflow_function_names, + aliases: HashMap::new(), + calls: Vec::new(), + } + } + + fn collect(mut self, function: &Function) -> Vec { + if let Some(body) = &function.body { + body.visit_with(&mut self); + } + self.calls + } + + fn resolve_symbol_name(&self, name: &str) -> Option { + if self.step_function_names.contains(name) { + Some(GraphNodeRef { + name: name.to_string(), + kind: GraphCallKind::Step, + }) + } else if self.workflow_function_names.contains(name) { + Some(GraphNodeRef { + name: name.to_string(), + kind: GraphCallKind::Workflow, + }) + } else { + None + } + } + + fn resolve_ident_direct(&self, ident: &Ident) -> Option { + self.resolve_symbol_name(&ident.sym.to_string()) + } + + fn resolve_ident(&self, ident: &Ident) -> Option { + if let Some(alias) = self.aliases.get(&ident.to_id()) { + return Some(alias.clone()); + } + self.resolve_ident_direct(ident) + } + + fn resolve_expr_to_node(&self, expr: &Expr) -> Option { + match expr { + Expr::Ident(ident) => self.resolve_ident(ident), + Expr::Paren(paren) => self.resolve_expr_to_node(&paren.expr), + Expr::Await(await_expr) => self.resolve_expr_to_node(&await_expr.arg), + _ => None, + } + } + + fn resolve_callee(&self, callee: &Callee) -> Option { + match callee { + Callee::Expr(expr) => self.resolve_expr_to_node(expr), + _ => None, + } + } + + fn record_usage(&mut self, node: GraphNodeRef, span: swc_core::common::Span) { + self.calls.push(GraphCallEvent { + fn_name: node.name, + span, + kind: node.kind, + }); + } +} + +impl<'a> Visit for GraphUsageCollector<'a> { + fn visit_call_expr(&mut self, call: &CallExpr) { + if let Some(node) = self.resolve_callee(&call.callee) { + self.record_usage(node, call.span); + } + match &call.callee { + Callee::Expr(expr) => { + if !matches!(expr.as_ref(), Expr::Ident(_)) { + expr.visit_with(self); + } + } + Callee::Super(super_callee) => { + super_callee.visit_with(self); + } + Callee::Import(import) => { + import.visit_with(self); + } + } + for arg in &call.args { + arg.visit_with(self); + } + if let Some(type_args) = &call.type_args { + type_args.visit_with(self); + } + } + + fn visit_expr(&mut self, expr: &Expr) { + match expr { + Expr::Ident(ident) => { + if let Some(node) = self.resolve_ident(ident) { + self.record_usage(node, ident.span); + } + } + _ => { + expr.visit_children_with(self); + } + } + } + + fn visit_var_declarator(&mut self, decl: &VarDeclarator) { + if let Some(init) = &decl.init { + if let Some(target) = self.resolve_expr_to_node(init) { + if let Pat::Ident(binding) = &decl.name { + self.aliases.insert(binding.id.to_id(), target); + } + } + } + decl.visit_children_with(self); + } +} + // Structure to track variable names and their access patterns #[derive(Debug, Clone, PartialEq, Eq)] struct Name { @@ -334,6 +488,10 @@ impl StepTransform { *stmt = Stmt::Empty(EmptyStmt { span: DUMMY_SP }); return; } + TransformMode::Graph => { + // No transformation in graph mode, but continue traversing + stmt.visit_mut_children_with(self); + } } } else { match self.mode { @@ -376,6 +534,10 @@ impl StepTransform { self.remove_use_step_directive(&mut fn_decl.function.body); stmt.visit_mut_children_with(self); } + TransformMode::Graph => { + // No transformation in graph mode, but continue traversing + stmt.visit_mut_children_with(self); + } } } } @@ -425,21 +587,99 @@ impl StepTransform { .push((fn_name.clone(), fn_decl.function.span)); stmt.visit_mut_children_with(self); } + TransformMode::Graph => { + // No transformation in graph mode + stmt.visit_mut_children_with(self); + } } } } else { stmt.visit_mut_children_with(self); } } - Stmt::Decl(Decl::Var(_)) => { - stmt.visit_mut_children_with(self); - } _ => { + // For all other statement types, continue traversing to detect nested step calls stmt.visit_mut_children_with(self); } } } + + fn queue_workflow_graph( + &mut self, + workflow_name: &str, + workflow_id: &str, + function: &Function, + ) { + if self.mode != TransformMode::Graph { + return; + } + self.pending_graph_workflows.push(( + workflow_name.to_string(), + workflow_id.to_string(), + function.clone(), + )); + } + + fn process_pending_workflow_graphs(&mut self) { + if self.mode != TransformMode::Graph { + return; + } + let pending = std::mem::take(&mut self.pending_graph_workflows); + for (workflow_name, workflow_id, function) in pending { + self.build_workflow_graph(&workflow_name, workflow_id, &function); + } + } + + fn build_workflow_graph( + &mut self, + workflow_name: &str, + workflow_id: String, + function: &Function, + ) { + if self.mode != TransformMode::Graph { + return; + } + + let Some(mut builder) = self.graph_builder.take() else { + return; + }; + + let collector = + GraphUsageCollector::new(&self.step_function_names, &self.workflow_function_names) + .collect(function); + eprintln!( + "[graph] collected {} calls for {}", + collector.len(), + workflow_name + ); + + let file_path = self.filename.clone(); + builder.start_workflow(workflow_name, &file_path, &workflow_id); + for event in collector { + let line = event.span.lo.0 as usize; + match event.kind { + GraphCallKind::Step => { + let step_id = self.create_id(Some(&event.fn_name), event.span, false); + builder.add_step_node(&event.fn_name, &step_id, line); + } + GraphCallKind::Workflow => { + let nested_id = self.create_id(Some(&event.fn_name), event.span, true); + builder.add_workflow_node(&event.fn_name, &nested_id, line); + } + } + } + builder.finish_workflow(); + + self.graph_builder = Some(builder); + } + pub fn new(mode: TransformMode, filename: String) -> Self { + let graph_builder = if mode == TransformMode::Graph { + Some(graph::GraphBuilder::new()) + } else { + None + }; + Self { mode, filename, @@ -466,6 +706,8 @@ impl StepTransform { nested_step_functions: Vec::new(), anonymous_fn_counter: 0, object_property_workflow_conversions: Vec::new(), + graph_builder, + pending_graph_workflows: Vec::new(), current_var_context: None, } } @@ -498,12 +740,12 @@ impl StepTransform { fn generate_unique_name(&self, base_name: &str) -> String { let mut name = base_name.to_string(); let mut counter = 0; - + while self.declared_identifiers.contains(&name) { counter += 1; name = format!("{}${}", base_name, counter); } - + name } @@ -511,10 +753,27 @@ impl StepTransform { fn collect_declared_identifiers(&mut self, items: &[ModuleItem]) { for item in items { match item { - ModuleItem::Stmt(Stmt::Decl(decl)) => { - match decl { + ModuleItem::Stmt(Stmt::Decl(decl)) => match decl { + Decl::Fn(fn_decl) => { + self.declared_identifiers + .insert(fn_decl.ident.sym.to_string()); + } + Decl::Var(var_decl) => { + for declarator in &var_decl.decls { + self.collect_idents_from_pat(&declarator.name); + } + } + Decl::Class(class_decl) => { + self.declared_identifiers + .insert(class_decl.ident.sym.to_string()); + } + _ => {} + }, + ModuleItem::ModuleDecl(module_decl) => match module_decl { + ModuleDecl::ExportDecl(export_decl) => match &export_decl.decl { Decl::Fn(fn_decl) => { - self.declared_identifiers.insert(fn_decl.ident.sym.to_string()); + self.declared_identifiers + .insert(fn_decl.ident.sym.to_string()); } Decl::Var(var_decl) => { for declarator in &var_decl.decls { @@ -522,60 +781,76 @@ impl StepTransform { } } Decl::Class(class_decl) => { - self.declared_identifiers.insert(class_decl.ident.sym.to_string()); + self.declared_identifiers + .insert(class_decl.ident.sym.to_string()); } _ => {} - } - } - ModuleItem::ModuleDecl(module_decl) => { - match module_decl { - ModuleDecl::ExportDecl(export_decl) => { - match &export_decl.decl { - Decl::Fn(fn_decl) => { - self.declared_identifiers.insert(fn_decl.ident.sym.to_string()); - } - Decl::Var(var_decl) => { - for declarator in &var_decl.decls { - self.collect_idents_from_pat(&declarator.name); - } - } - Decl::Class(class_decl) => { - self.declared_identifiers.insert(class_decl.ident.sym.to_string()); - } - _ => {} + }, + ModuleDecl::ExportDefaultDecl(default_decl) => match &default_decl.decl { + DefaultDecl::Fn(fn_expr) => { + if let Some(ident) = &fn_expr.ident { + self.declared_identifiers.insert(ident.sym.to_string()); } } - ModuleDecl::ExportDefaultDecl(default_decl) => { - match &default_decl.decl { - DefaultDecl::Fn(fn_expr) => { - if let Some(ident) = &fn_expr.ident { - self.declared_identifiers.insert(ident.sym.to_string()); - } - } - DefaultDecl::Class(class_expr) => { - if let Some(ident) = &class_expr.ident { - self.declared_identifiers.insert(ident.sym.to_string()); - } - } - _ => {} + DefaultDecl::Class(class_expr) => { + if let Some(ident) = &class_expr.ident { + self.declared_identifiers.insert(ident.sym.to_string()); } } - ModuleDecl::Import(import_decl) => { - for specifier in &import_decl.specifiers { - match specifier { - ImportSpecifier::Named(named) => { - self.declared_identifiers.insert(named.local.sym.to_string()); - } - ImportSpecifier::Default(default) => { - self.declared_identifiers.insert(default.local.sym.to_string()); - } - ImportSpecifier::Namespace(namespace) => { - self.declared_identifiers.insert(namespace.local.sym.to_string()); - } + _ => {} + }, + ModuleDecl::Import(import_decl) => { + for specifier in &import_decl.specifiers { + match specifier { + ImportSpecifier::Named(named) => { + self.declared_identifiers + .insert(named.local.sym.to_string()); + } + ImportSpecifier::Default(default) => { + self.declared_identifiers + .insert(default.local.sym.to_string()); + } + ImportSpecifier::Namespace(namespace) => { + self.declared_identifiers + .insert(namespace.local.sym.to_string()); } } } - _ => {} + } + _ => {} + }, + _ => {} + } + } + } + + // Collect step and workflow function names in a pre-pass (for graph mode) + // This ensures we know about all step functions before traversing workflow bodies + fn collect_workflow_metadata(&mut self, items: &[ModuleItem]) { + for item in items { + match item { + ModuleItem::Stmt(Stmt::Decl(Decl::Fn(fn_decl))) => { + self.collect_function_metadata( + &fn_decl.function, + &fn_decl.ident.sym.to_string(), + ); + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export_decl)) => { + if let Decl::Fn(fn_decl) = &export_decl.decl { + self.collect_function_metadata( + &fn_decl.function, + &fn_decl.ident.sym.to_string(), + ); + } + } + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(default_decl)) => { + if let DefaultDecl::Fn(fn_expr) = &default_decl.decl { + if let Some(ident) = &fn_expr.ident { + self.collect_function_metadata( + &fn_expr.function, + &ident.sym.to_string(), + ); + } } } _ => {} @@ -583,6 +858,23 @@ impl StepTransform { } } + // Helper to check if a function has step or workflow directive and collect metadata + fn collect_function_metadata(&mut self, func: &Function, name: &str) { + if let Some(body) = &func.body { + if let Some(first_stmt) = body.stmts.first() { + if let Stmt::Expr(ExprStmt { expr, .. }) = first_stmt { + if let Expr::Lit(Lit::Str(Str { value, .. })) = &**expr { + if value == "use step" { + self.step_function_names.insert(name.to_string()); + } else if value == "use workflow" { + self.workflow_function_names.insert(name.to_string()); + } + } + } + } + } + } + // Helper to collect identifiers from patterns (for destructuring, etc.) fn collect_idents_from_pat(&mut self, pat: &Pat) { match pat { @@ -859,6 +1151,9 @@ impl StepTransform { TransformMode::Client => { // In client mode, just remove the directive (already done above) } + TransformMode::Graph => { + // No transformation in graph mode + } } } } @@ -907,6 +1202,9 @@ impl StepTransform { TransformMode::Client => { // In client mode, just remove the directive } + TransformMode::Graph => { + // No transformation in graph mode + } } } @@ -1957,7 +2255,8 @@ impl StepTransform { .map(|fn_name| { // Check if this export name has a different const name (e.g., "default" -> "__default") let fn_name_str: &str = fn_name; - let actual_name = self.workflow_export_to_const_name + let actual_name = self + .workflow_export_to_const_name .get(fn_name_str) .map(|s| s.as_str()) .unwrap_or(fn_name_str); @@ -2189,9 +2488,22 @@ impl<'a> VisitMut for ComprehensiveUsageCollector<'a> { impl VisitMut for StepTransform { fn visit_mut_program(&mut self, program: &mut Program) { + // In Graph mode, do a pre-pass to collect all step and workflow function names + // This ensures we know about all step functions before we start building the graph + if self.mode == TransformMode::Graph { + if let Program::Module(module) = program { + self.collect_workflow_metadata(&module.body); + } + } + // First pass: collect step functions program.visit_mut_children_with(self); + // Ensure graph data is processed after traversing entire program + if self.mode == TransformMode::Graph { + self.process_pending_workflow_graphs(); + } + // Add necessary imports and registrations match program { Program::Module(module) => { @@ -2212,6 +2524,41 @@ impl VisitMut for StepTransform { TransformMode::Client => { // No imports needed for client mode since step functions are not transformed } + TransformMode::Graph => { + // Output graph as a comment in graph mode + if let Some(builder) = self.graph_builder.take() { + if builder.has_workflows() { + let manifest = builder.to_manifest(); + // Output as a comment similar to workflow metadata + if let Ok(json) = serde_json::to_string(&manifest) { + let comment = format!("/**__workflow_graph{}*/", json); + // Insert the graph comment at the beginning of the file + let insert_position = module + .body + .iter() + .position(|item| { + !matches!( + item, + ModuleItem::ModuleDecl(ModuleDecl::Import(_)) + ) + }) + .unwrap_or(0); + + module.body.insert( + insert_position, + ModuleItem::Stmt(Stmt::Expr(ExprStmt { + span: DUMMY_SP, + expr: Box::new(Expr::Lit(Lit::Str(Str { + span: DUMMY_SP, + value: comment.clone().into(), + raw: Some(comment.into()), + }))), + })), + ); + } + } + } + } } // Add imports at the beginning @@ -2421,6 +2768,9 @@ impl VisitMut for StepTransform { TransformMode::Client => { // No imports needed for workflow mode } + TransformMode::Graph => { + // No imports needed for graph mode + } } // Convert script statements to module items @@ -2586,7 +2936,13 @@ impl VisitMut for StepTransform { fn visit_mut_module_items(&mut self, items: &mut Vec) { // Collect all declared identifiers to avoid naming collisions self.collect_declared_identifiers(items); - + + // In Graph mode, collect all step and workflow function names first + // This ensures we know about all step functions before we start building the graph + if self.mode == TransformMode::Graph { + self.collect_workflow_metadata(items); + } + // Check for file-level directives self.has_file_step_directive = self.check_module_directive(items); self.has_file_workflow_directive = self.check_module_workflow_directive(items); @@ -2599,6 +2955,7 @@ impl VisitMut for StepTransform { TransformMode::Step => value == "use step", TransformMode::Workflow => value == "use workflow", TransformMode::Client => value == "use step" || value == "use workflow", + TransformMode::Graph => false, }; if should_remove { items.remove(0); @@ -2922,8 +3279,9 @@ impl VisitMut for StepTransform { // Handle default workflow exports (workflow and client modes) // We need to: 1) find the export default position, 2) replace it with const declaration, // 3) add workflowId assignment, 4) add export default at the end - if (self.mode == TransformMode::Workflow || self.mode == TransformMode::Client) - && !self.default_workflow_exports.is_empty() { + if (self.mode == TransformMode::Workflow || self.mode == TransformMode::Client) + && !self.default_workflow_exports.is_empty() + { let default_workflows: Vec<_> = self.default_workflow_exports.drain(..).collect(); let default_exports: Vec<_> = self.default_exports_to_replace.drain(..).collect(); @@ -2931,8 +3289,8 @@ impl VisitMut for StepTransform { let mut export_position = None; for (i, item) in items.iter().enumerate() { match item { - ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(_)) | - ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(_)) => { + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(_)) + | ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(_)) => { export_position = Some(i); break; } @@ -2947,35 +3305,46 @@ impl VisitMut for StepTransform { // Insert in correct order: const, workflowId, export default for (const_name, fn_expr, span) in default_workflows { // Insert const declaration at the original export position - items.insert(pos, ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(VarDecl { - span: DUMMY_SP, - ctxt: SyntaxContext::empty(), - kind: VarDeclKind::Const, - declare: false, - decls: vec![VarDeclarator { + items.insert( + pos, + ModuleItem::Stmt(Stmt::Decl(Decl::Var(Box::new(VarDecl { span: DUMMY_SP, - name: Pat::Ident(BindingIdent { - id: Ident::new(const_name.clone().into(), DUMMY_SP, SyntaxContext::empty()), - type_ann: None, - }), - init: Some(Box::new(fn_expr)), - definite: false, - }], - }))))); - + ctxt: SyntaxContext::empty(), + kind: VarDeclKind::Const, + declare: false, + decls: vec![VarDeclarator { + span: DUMMY_SP, + name: Pat::Ident(BindingIdent { + id: Ident::new( + const_name.clone().into(), + DUMMY_SP, + SyntaxContext::empty(), + ), + type_ann: None, + }), + init: Some(Box::new(fn_expr)), + definite: false, + }], + })))), + ); + // Insert workflowId assignment after const - items.insert(pos + 1, ModuleItem::Stmt( - self.create_workflow_id_assignment(&const_name, span), - )); + items.insert( + pos + 1, + ModuleItem::Stmt(self.create_workflow_id_assignment(&const_name, span)), + ); // Insert export default at the end (after workflowId) for (_export_name, replacement_expr) in &default_exports { - items.insert(pos + 2, ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr( - ExportDefaultExpr { - span: DUMMY_SP, - expr: Box::new(replacement_expr.clone()), - }, - ))); + items.insert( + pos + 2, + ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr( + ExportDefaultExpr { + span: DUMMY_SP, + expr: Box::new(replacement_expr.clone()), + }, + )), + ); } } } @@ -3212,6 +3581,9 @@ impl VisitMut for StepTransform { // Step functions are completely removed in client mode // This will be handled at a higher level } + TransformMode::Graph => { + // No transformation in graph mode + } } } } else if self.has_workflow_directive(&fn_decl.function, false) { @@ -3225,6 +3597,7 @@ impl VisitMut for StepTransform { // It's valid - proceed with transformation self.workflow_function_names.insert(fn_name.clone()); + let mut graph_workflow_id: Option = None; match self.mode { TransformMode::Step => { // Workflow functions are not processed in step mode @@ -3237,7 +3610,25 @@ impl VisitMut for StepTransform { // Workflow functions are transformed in client mode // This will be handled at a higher level } + TransformMode::Graph => { + // Start tracking this workflow in graph mode + let workflow_id = + self.create_id(Some(&fn_name), fn_decl.function.span, true); + graph_workflow_id = Some(workflow_id); + // Set flag so we can detect step calls inside the workflow + self.in_workflow_function = true; + } } + + fn_decl.visit_mut_children_with(self); + + // Finish workflow graph if we started one + if let Some(workflow_id) = graph_workflow_id { + self.queue_workflow_graph(&fn_name, &workflow_id, &fn_decl.function); + // Reset the flag + self.in_workflow_function = false; + } + return; } } @@ -3315,6 +3706,9 @@ impl VisitMut for StepTransform { self.remove_use_step_directive(&mut fn_decl.function.body); export_decl.visit_mut_children_with(self); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } else if is_workflow_function { @@ -3395,6 +3789,19 @@ impl VisitMut for StepTransform { self.workflow_functions_needing_id .push((fn_name.clone(), fn_decl.function.span)); } + TransformMode::Graph => { + let workflow_id = + self.create_id(Some(&fn_name), fn_decl.function.span, true); + self.in_workflow_function = true; + fn_decl.visit_mut_children_with(self); + self.queue_workflow_graph( + &fn_name, + &workflow_id, + &fn_decl.function, + ); + self.in_workflow_function = old_in_workflow; + return; + } } } // Visit children for workflow functions OUTSIDE the match to avoid borrow issues @@ -3485,6 +3892,9 @@ impl VisitMut for StepTransform { &mut fn_expr.function.body, ); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } else if self @@ -3567,8 +3977,13 @@ impl VisitMut for StepTransform { fn_expr.function.span, )); } + TransformMode::Graph => { + // No transformation in graph mode + } } } + + self.in_workflow_function = old_in_workflow; } } Expr::Arrow(arrow_expr) => { @@ -3615,6 +4030,9 @@ impl VisitMut for StepTransform { &mut arrow_expr.body, ); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } else if self.has_workflow_directive_arrow(arrow_expr, true) { @@ -3700,6 +4118,9 @@ impl VisitMut for StepTransform { self.workflow_functions_needing_id .push((name.clone(), arrow_expr.span)); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } @@ -3841,6 +4262,9 @@ impl VisitMut for StepTransform { &mut fn_expr.function.body, ); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } else if self.has_workflow_directive(&fn_expr.function, false) { @@ -3901,6 +4325,9 @@ impl VisitMut for StepTransform { self.workflow_functions_needing_id .push((name.clone(), fn_expr.function.span)); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } @@ -4005,6 +4432,9 @@ impl VisitMut for StepTransform { span: DUMMY_SP, })); } + TransformMode::Graph => { + // No transformation in graph mode + } } } else { // Not in a workflow function - handle normally @@ -4062,6 +4492,9 @@ impl VisitMut for StepTransform { &mut arrow_expr.body, ); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } @@ -4126,6 +4559,9 @@ impl VisitMut for StepTransform { self.workflow_functions_needing_id .push((name.clone(), arrow_expr.span)); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } @@ -4267,14 +4703,16 @@ impl VisitMut for StepTransform { let const_name = if fn_name == "default" { // Anonymous: generate unique name let unique_name = self.generate_unique_name("__default"); - self.workflow_export_to_const_name.insert("default".to_string(), unique_name.clone()); + self.workflow_export_to_const_name + .insert("default".to_string(), unique_name.clone()); unique_name } else { // Named: use the function name - self.workflow_export_to_const_name.insert("default".to_string(), fn_name.clone()); + self.workflow_export_to_const_name + .insert("default".to_string(), fn_name.clone()); fn_name.clone() }; - + // Always use "default" as the metadata key for default exports self.workflow_function_names.insert("default".to_string()); @@ -4294,7 +4732,7 @@ impl VisitMut for StepTransform { Expr::Fn(fn_expr.clone()), fn_expr.function.span, )); - + // Track for replacement with identifier self.default_exports_to_replace.push(( fn_name.clone(), @@ -4318,7 +4756,7 @@ impl VisitMut for StepTransform { TransformMode::Client => { // In client mode, replace workflow function body with error throw self.remove_use_workflow_directive(&mut fn_expr.function.body); - + let error_msg = format!( "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api", const_name, const_name @@ -4347,7 +4785,7 @@ impl VisitMut for StepTransform { arg: Box::new(error_expr), })]; } - + // For anonymous functions, convert to const declaration so we can assign workflowId if fn_name == "default" { // Track for const declaration and workflowId assignment @@ -4356,7 +4794,7 @@ impl VisitMut for StepTransform { Expr::Fn(fn_expr.clone()), fn_expr.function.span, )); - + // Track for replacement with identifier self.default_exports_to_replace.push(( fn_name.clone(), @@ -4373,6 +4811,9 @@ impl VisitMut for StepTransform { .push((const_name, fn_expr.function.span)); } } + TransformMode::Graph => { + // No transformation in graph mode + } } } } else if self.should_transform_function(&fn_expr.function, true) { @@ -4423,6 +4864,9 @@ impl VisitMut for StepTransform { // Transform step function body to use step run call self.remove_use_step_directive(&mut fn_expr.function.body); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } @@ -4445,8 +4889,9 @@ impl VisitMut for StepTransform { // Generate unique name first so we can use it in workflow_function_names let unique_name = self.generate_unique_name("__default"); // For function expression default exports, track mapping from "default" to actual const name - self.workflow_export_to_const_name.insert("default".to_string(), unique_name.clone()); - + self.workflow_export_to_const_name + .insert("default".to_string(), unique_name.clone()); + // Always use "default" as the metadata key for default exports self.workflow_function_names.insert("default".to_string()); @@ -4457,14 +4902,14 @@ impl VisitMut for StepTransform { TransformMode::Workflow => { // In workflow mode, convert to const declaration self.remove_use_workflow_directive(&mut fn_expr.function.body); - + // Track for const declaration and workflowId assignment self.default_workflow_exports.push(( unique_name.clone(), Expr::Fn(fn_expr.clone()), fn_expr.function.span, )); - + // Track for replacement with identifier self.default_exports_to_replace.push(( "default".to_string(), @@ -4506,14 +4951,14 @@ impl VisitMut for StepTransform { arg: Box::new(error_expr), })]; } - + // Track for const declaration and workflowId assignment self.default_workflow_exports.push(( unique_name.clone(), Expr::Fn(fn_expr.clone()), fn_expr.function.span, )); - + // Track for replacement with identifier self.default_exports_to_replace.push(( "default".to_string(), @@ -4524,6 +4969,9 @@ impl VisitMut for StepTransform { )), )); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } else if self.should_transform_function(&fn_expr.function, true) { @@ -4545,11 +4993,12 @@ impl VisitMut for StepTransform { } else { // For arrow function default exports, generate unique name and track mapping let unique_name = self.generate_unique_name("__default"); - self.workflow_export_to_const_name.insert("default".to_string(), unique_name.clone()); - + self.workflow_export_to_const_name + .insert("default".to_string(), unique_name.clone()); + // Always use "default" as the metadata key for default exports self.workflow_function_names.insert("default".to_string()); - + match self.mode { TransformMode::Step => { // Workflow functions are not processed in step mode @@ -4557,14 +5006,14 @@ impl VisitMut for StepTransform { TransformMode::Workflow => { // In workflow mode, convert to const declaration self.remove_use_workflow_directive_arrow(&mut arrow_expr.body); - + // Track for const declaration and workflowId assignment self.default_workflow_exports.push(( unique_name.clone(), Expr::Arrow(arrow_expr.clone()), arrow_expr.span, )); - + // Track for replacement with identifier self.default_exports_to_replace.push(( "default".to_string(), @@ -4609,14 +5058,14 @@ impl VisitMut for StepTransform { arg: Box::new(error_expr), })], })); - + // Track for const declaration and workflowId assignment self.default_workflow_exports.push(( unique_name.clone(), Expr::Arrow(arrow_expr.clone()), arrow_expr.span, )); - + // Track for replacement with identifier self.default_exports_to_replace.push(( "default".to_string(), @@ -4627,6 +5076,9 @@ impl VisitMut for StepTransform { )), )); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } else if self.has_step_directive_arrow(arrow_expr, true) { @@ -4797,6 +5249,9 @@ impl VisitMut for StepTransform { &mut arrow_expr.body, ); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } @@ -4867,6 +5322,9 @@ impl VisitMut for StepTransform { &mut fn_expr.function.body, ); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } @@ -4953,6 +5411,9 @@ impl VisitMut for StepTransform { &mut method_prop.function.body, ); } + TransformMode::Graph => { + // No transformation in graph mode + } } } } diff --git a/packages/swc-plugin-workflow/transform/tests/graph.rs b/packages/swc-plugin-workflow/transform/tests/graph.rs new file mode 100644 index 000000000..5a9578018 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/graph.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; +use swc_core::ecma::{ + transforms::testing::{FixtureTestConfig, test_fixture}, + visit::visit_mut_pass, +}; +use swc_workflow::{StepTransform, TransformMode}; + +#[testing::fixture("tests/graph/**/input.js")] +fn graph_mode(input: PathBuf) { + let graph_output = input.parent().unwrap().join("output-graph.js"); + test_fixture( + Default::default(), + &|_| { + visit_mut_pass(StepTransform::new( + TransformMode::Graph, + input.file_name().unwrap().to_string_lossy().to_string(), + )) + }, + &input, + &graph_output, + FixtureTestConfig { + module: Some(true), + ..Default::default() + }, + ); +} diff --git a/packages/swc-plugin-workflow/transform/tests/graph/loop-step-call/input.js b/packages/swc-plugin-workflow/transform/tests/graph/loop-step-call/input.js new file mode 100644 index 000000000..2acfe8fd5 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/graph/loop-step-call/input.js @@ -0,0 +1,12 @@ +export async function loopWorkflow(items) { + 'use workflow'; + + for (const item of items) { + await stepFn(item); + } +} + +async function stepFn(value) { + 'use step'; + return value; +} diff --git a/packages/swc-plugin-workflow/transform/tests/graph/loop-step-call/output-graph.js b/packages/swc-plugin-workflow/transform/tests/graph/loop-step-call/output-graph.js new file mode 100644 index 000000000..3b97477f5 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/graph/loop-step-call/output-graph.js @@ -0,0 +1,12 @@ +/**__internal_workflows{"workflows":{"input.js":{"loopWorkflow":{"workflowId":"workflow//input.js//loopWorkflow"}}},"steps":{"input.js":{"stepFn":{"stepId":"step//input.js//stepFn"}}}}*/ +/**__workflow_graph{"version":"1.0.0","workflows":{"loopWorkflow":{"workflowId":"workflow//input.js//loopWorkflow","workflowName":"loopWorkflow","filePath":"input.js","nodes":[{"id":"start","type":"workflowStart","position":{"x":250.0,"y":0.0},"data":{"label":"Start: loopWorkflow","nodeKind":"workflow_start","line":0}},{"id":"node_1","type":"step","position":{"x":250.0,"y":100.0},"data":{"label":"stepFn","nodeKind":"step","stepId":"step//input.js//stepFn","line":104}},{"id":"end","type":"workflowEnd","position":{"x":250.0,"y":200.0},"data":{"label":"Return","nodeKind":"workflow_end","line":0}}],"edges":[{"id":"e_start_node_1","source":"start","target":"node_1","type":"default"},{"id":"e_node_1_end","source":"node_1","target":"end","type":"default"}]}}}*/ +export async function loopWorkflow(items) { + 'use workflow'; + for (const item of items) { + await stepFn(item); + } +} +async function stepFn(value) { + 'use step'; + return value; +} diff --git a/packages/swc-plugin-workflow/transform/tests/graph/map-callback/input.js b/packages/swc-plugin-workflow/transform/tests/graph/map-callback/input.js new file mode 100644 index 000000000..0c5e8966e --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/graph/map-callback/input.js @@ -0,0 +1,10 @@ +async function logItem(item) { + 'use step'; + console.log(item); +} + +export async function batchOverSteps(items) { + 'use workflow'; + + await Promise.all(items.map(logItem)); +} diff --git a/packages/swc-plugin-workflow/transform/tests/graph/map-callback/output-graph.js b/packages/swc-plugin-workflow/transform/tests/graph/map-callback/output-graph.js new file mode 100644 index 000000000..0925323e6 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/graph/map-callback/output-graph.js @@ -0,0 +1,10 @@ +/**__internal_workflows{"workflows":{"input.js":{"batchOverSteps":{"workflowId":"workflow//input.js//batchOverSteps"}}},"steps":{"input.js":{"logItem":{"stepId":"step//input.js//logItem"}}}}*/ +/**__workflow_graph{"version":"1.0.0","workflows":{"batchOverSteps":{"workflowId":"workflow//input.js//batchOverSteps","workflowName":"batchOverSteps","filePath":"input.js","nodes":[{"id":"start","type":"workflowStart","position":{"x":250.0,"y":0.0},"data":{"label":"Start: batchOverSteps","nodeKind":"workflow_start","line":0}},{"id":"node_1","type":"step","position":{"x":250.0,"y":100.0},"data":{"label":"logItem","nodeKind":"step","stepId":"step//input.js//logItem","line":165}},{"id":"end","type":"workflowEnd","position":{"x":250.0,"y":200.0},"data":{"label":"Return","nodeKind":"workflow_end","line":0}}],"edges":[{"id":"e_start_node_1","source":"start","target":"node_1","type":"default"},{"id":"e_node_1_end","source":"node_1","target":"end","type":"default"}]}}}*/ +async function logItem(item) { + 'use step'; + console.log(item); +} +export async function batchOverSteps(items) { + 'use workflow'; + await Promise.all(items.map(logItem)); +} diff --git a/packages/swc-plugin-workflow/transform/tests/graph/tool-reference/input.js b/packages/swc-plugin-workflow/transform/tests/graph/tool-reference/input.js new file mode 100644 index 000000000..b0b96e3f7 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/graph/tool-reference/input.js @@ -0,0 +1,17 @@ +async function getWeatherInformation(city) { + 'use step'; + return `Weather for ${city}`; +} + +export async function agent(prompt) { + 'use workflow'; + + await generate({ + prompt, + tools: { + weather: { + execute: getWeatherInformation, + }, + }, + }); +} diff --git a/packages/swc-plugin-workflow/transform/tests/graph/tool-reference/output-graph.js b/packages/swc-plugin-workflow/transform/tests/graph/tool-reference/output-graph.js new file mode 100644 index 000000000..ea75abb5c --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/graph/tool-reference/output-graph.js @@ -0,0 +1,17 @@ +/**__internal_workflows{"workflows":{"input.js":{"agent":{"workflowId":"workflow//input.js//agent"}}},"steps":{"input.js":{"getWeatherInformation":{"stepId":"step//input.js//getWeatherInformation"}}}}*/ +/**__workflow_graph{"version":"1.0.0","workflows":{"agent":{"workflowId":"workflow//input.js//agent","workflowName":"agent","filePath":"input.js","nodes":[{"id":"start","type":"workflowStart","position":{"x":250.0,"y":0.0},"data":{"label":"Start: agent","nodeKind":"workflow_start","line":0}},{"id":"node_1","type":"step","position":{"x":250.0,"y":100.0},"data":{"label":"getWeatherInformation","nodeKind":"step","stepId":"step//input.js//getWeatherInformation","line":230}},{"id":"end","type":"workflowEnd","position":{"x":250.0,"y":200.0},"data":{"label":"Return","nodeKind":"workflow_end","line":0}}],"edges":[{"id":"e_start_node_1","source":"start","target":"node_1","type":"default"},{"id":"e_node_1_end","source":"node_1","target":"end","type":"default"}]}}}*/ +async function getWeatherInformation(city) { + 'use step'; + return `Weather for ${city}`; +} +export async function agent(prompt) { + 'use workflow'; + await generate({ + prompt, + tools: { + weather: { + execute: getWeatherInformation, + }, + }, + }); +} diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index ee1a7527b..3143c5524 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -64,7 +64,7 @@ export const getErrorMessage = (error: Error | WorkflowAPIError): string => { /** * Helper to handle server action results and throw WorkflowAPIError on failure */ -function unwrapServerActionResult(result: { +export function unwrapServerActionResult(result: { success: boolean; data?: T; error?: ServerActionError; diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 66230d452..fe0a98ed8 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -499,3 +499,40 @@ export async function readStreamServerAction( }; } } + +/** + * Fetch the workflow graph manifest from the data directory + * The manifest is generated at build time and contains static structure info about workflows + */ +export async function fetchGraphManifest( + worldEnv: EnvMap +): Promise> { + try { + // Get the data directory from the world environment config + // This contains the correct absolute path passed from the client + const dataDir = + worldEnv.WORKFLOW_EMBEDDED_DATA_DIR || + process.env.WORKFLOW_EMBEDDED_DATA_DIR || + '.next/workflow-data'; + + // Read the manifest file + const fs = await import('fs/promises'); + const path = await import('path'); + + // If dataDir is absolute, use it directly; otherwise join with cwd + const fullPath = path.isAbsolute(dataDir) + ? path.join(dataDir, 'graph-manifest.json') + : path.join(process.cwd(), dataDir, 'graph-manifest.json'); + + const content = await fs.readFile(fullPath, 'utf-8'); + const manifest = JSON.parse(content); + + return createResponse(manifest); + } catch (error) { + console.error('Failed to fetch graph manifest:', error); + return { + success: false, + error: createServerActionError(error, 'fetchGraphManifest', {}), + }; + } +} diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 60e9d2465..f1592da95 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -5,6 +5,11 @@ export { export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; export * from './api/workflow-api-client'; +export type { EnvMap } from './api/workflow-server-actions'; +export { + fetchEventsByCorrelationId, + fetchGraphManifest, +} from './api/workflow-server-actions'; export { RunTraceView } from './run-trace-view'; export type { Span, SpanEvent } from './trace-viewer/types'; export { WorkflowTraceViewer } from './workflow-trace-view'; diff --git a/packages/web/package.json b/packages/web/package.json index f46a757d2..641de4da7 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,11 +26,16 @@ "format": "biome format --write" }, "dependencies": { - "next": "15.5.4" + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-tabs": "^1.1.13", + "@xyflow/react": "12.9.3", + "next": "15.5.4", + "nuqs": "^2.2.5" }, "devDependencies": { "@biomejs/biome": "catalog:", "@radix-ui/react-alert-dialog": "1.1.5", + "@radix-ui/react-dropdown-menu": "2.1.6", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-slot": "1.1.1", @@ -41,8 +46,8 @@ "@types/react": "19", "@types/react-dom": "19", "@workflow/core": "workspace:*", - "@workflow/world": "workspace:*", "@workflow/web-shared": "workspace:*", + "@workflow/world": "workspace:*", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "date-fns": "4.1.0", diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index eba38b97a..7d6c3cb3e 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -2,30 +2,83 @@ /* Scan web-shared package for Tailwind classes */ @source "../../../web-shared/src"; +@source "../components"; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --radius: 0.5rem; - - /* Geist Design System Colors */ + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + /* Geist Design System Colors - Light Mode */ /* Backgrounds */ --ds-background-100: rgb(255 255 255); --ds-background-200: rgb(250 250 250); @@ -42,17 +95,6 @@ --ds-gray-900: rgb(23 23 23); --ds-gray-1000: rgb(0 0 0); - /* Gray Alpha (with transparency) */ - --ds-gray-alpha-100: rgb(0 0 0 / 0.02); - --ds-gray-alpha-200: rgb(0 0 0 / 0.04); - --ds-gray-alpha-300: rgb(0 0 0 / 0.08); - --ds-gray-alpha-400: rgb(0 0 0 / 0.12); - --ds-gray-alpha-500: rgb(0 0 0 / 0.3); - --ds-gray-alpha-600: rgb(0 0 0 / 0.5); - --ds-gray-alpha-700: rgb(0 0 0 / 0.6); - --ds-gray-alpha-800: rgb(0 0 0 / 0.7); - --ds-gray-alpha-900: rgb(0 0 0 / 0.9); - /* Blue */ --ds-blue-100: rgb(224 242 254); --ds-blue-200: rgb(186 230 253); @@ -101,18 +143,6 @@ --ds-green-900: rgb(20 83 45); --ds-green-1000: rgb(5 46 22); - /* Teal */ - --ds-teal-100: rgb(204 251 241); - --ds-teal-200: rgb(153 246 228); - --ds-teal-300: rgb(94 234 212); - --ds-teal-400: rgb(45 212 191); - --ds-teal-500: rgb(20 184 166); - --ds-teal-600: rgb(13 148 136); - --ds-teal-700: rgb(15 118 110); - --ds-teal-800: rgb(17 94 89); - --ds-teal-900: rgb(19 78 74); - --ds-teal-1000: rgb(4 47 46); - /* Purple */ --ds-purple-100: rgb(243 232 255); --ds-purple-200: rgb(233 213 255); @@ -125,17 +155,17 @@ --ds-purple-900: rgb(88 28 135); --ds-purple-1000: rgb(59 7 100); - /* Pink */ - --ds-pink-100: rgb(252 231 243); - --ds-pink-200: rgb(251 207 232); - --ds-pink-300: rgb(249 168 212); - --ds-pink-400: rgb(244 114 182); - --ds-pink-500: rgb(236 72 153); - --ds-pink-600: rgb(219 39 119); - --ds-pink-700: rgb(190 24 93); - --ds-pink-800: rgb(157 23 77); - --ds-pink-900: rgb(131 24 67); - --ds-pink-1000: rgb(80 7 36); + /* Teal */ + --ds-teal-100: rgb(204 251 241); + --ds-teal-200: rgb(153 246 228); + --ds-teal-300: rgb(94 234 212); + --ds-teal-400: rgb(45 212 191); + --ds-teal-500: rgb(20 184 166); + --ds-teal-600: rgb(13 148 136); + --ds-teal-700: rgb(15 118 110); + --ds-teal-800: rgb(17 94 89); + --ds-teal-900: rgb(19 78 74); + --ds-teal-1000: rgb(4 47 46); /* Shadows */ --ds-shadow-small: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.05), 0 12px 24px rgba(0, 0, 0, 0.05); @@ -143,169 +173,38 @@ --ds-shadow-large: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 8px 16px rgba(0, 0, 0, 0.10), 0 48px 96px rgba(0, 0, 0, 0.10); } -/* Dark mode: applies when system prefers dark (and no .light class override) */ -@media (prefers-color-scheme: dark) { - :root:not(.light) { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - - /* Geist Design System Colors - Dark Mode */ - /* Backgrounds */ - --ds-background-100: rgb(0 0 0); - --ds-background-200: rgb(10 10 10); - - /* Gray Scale */ - --ds-gray-100: rgb(17 17 17); - --ds-gray-200: rgb(23 23 23); - --ds-gray-300: rgb(41 41 41); - --ds-gray-400: rgb(64 64 64); - --ds-gray-500: rgb(115 115 115); - --ds-gray-600: rgb(163 163 163); - --ds-gray-700: rgb(212 212 212); - --ds-gray-800: rgb(229 229 229); - --ds-gray-900: rgb(245 245 245); - --ds-gray-1000: rgb(255 255 255); - - /* Gray Alpha (with transparency) */ - --ds-gray-alpha-100: rgb(255 255 255 / 0.02); - --ds-gray-alpha-200: rgb(255 255 255 / 0.04); - --ds-gray-alpha-300: rgb(255 255 255 / 0.08); - --ds-gray-alpha-400: rgb(255 255 255 / 0.12); - --ds-gray-alpha-500: rgb(255 255 255 / 0.3); - --ds-gray-alpha-600: rgb(255 255 255 / 0.5); - --ds-gray-alpha-700: rgb(255 255 255 / 0.6); - --ds-gray-alpha-800: rgb(255 255 255 / 0.7); - --ds-gray-alpha-900: rgb(255 255 255 / 0.9); - - /* Blue - Dark Mode */ - --ds-blue-100: rgb(8 47 73); - --ds-blue-200: rgb(12 74 110); - --ds-blue-300: rgb(7 89 133); - --ds-blue-400: rgb(3 105 161); - --ds-blue-500: rgb(2 132 199); - --ds-blue-600: rgb(14 165 233); - --ds-blue-700: rgb(56 189 248); - --ds-blue-800: rgb(125 211 252); - --ds-blue-900: rgb(186 230 253); - --ds-blue-1000: rgb(224 242 254); - - /* Red - Dark Mode */ - --ds-red-100: rgb(69 10 10); - --ds-red-200: rgb(127 29 29); - --ds-red-300: rgb(153 27 27); - --ds-red-400: rgb(185 28 28); - --ds-red-500: rgb(220 38 38); - --ds-red-600: rgb(239 68 68); - --ds-red-700: rgb(248 113 113); - --ds-red-800: rgb(252 165 165); - --ds-red-900: rgb(254 202 202); - --ds-red-1000: rgb(254 226 226); - - /* Amber - Dark Mode */ - --ds-amber-100: rgb(69 26 3); - --ds-amber-200: rgb(120 53 15); - --ds-amber-300: rgb(146 64 14); - --ds-amber-400: rgb(180 83 9); - --ds-amber-500: rgb(217 119 6); - --ds-amber-600: rgb(245 158 11); - --ds-amber-700: rgb(251 191 36); - --ds-amber-800: rgb(252 211 77); - --ds-amber-900: rgb(253 230 138); - --ds-amber-1000: rgb(254 243 199); - - /* Green - Dark Mode */ - --ds-green-100: rgb(5 46 22); - --ds-green-200: rgb(20 83 45); - --ds-green-300: rgb(22 101 52); - --ds-green-400: rgb(21 128 61); - --ds-green-500: rgb(22 163 74); - --ds-green-600: rgb(34 197 94); - --ds-green-700: rgb(74 222 128); - --ds-green-800: rgb(134 239 172); - --ds-green-900: rgb(187 247 208); - --ds-green-1000: rgb(220 252 231); - - /* Teal - Dark Mode */ - --ds-teal-100: rgb(4 47 46); - --ds-teal-200: rgb(19 78 74); - --ds-teal-300: rgb(17 94 89); - --ds-teal-400: rgb(15 118 110); - --ds-teal-500: rgb(13 148 136); - --ds-teal-600: rgb(20 184 166); - --ds-teal-700: rgb(45 212 191); - --ds-teal-800: rgb(94 234 212); - --ds-teal-900: rgb(153 246 228); - --ds-teal-1000: rgb(204 251 241); - - /* Purple - Dark Mode */ - --ds-purple-100: rgb(59 7 100); - --ds-purple-200: rgb(88 28 135); - --ds-purple-300: rgb(107 33 168); - --ds-purple-400: rgb(126 34 206); - --ds-purple-500: rgb(147 51 234); - --ds-purple-600: rgb(168 85 247); - --ds-purple-700: rgb(192 132 252); - --ds-purple-800: rgb(216 180 254); - --ds-purple-900: rgb(233 213 255); - --ds-purple-1000: rgb(243 232 255); - - /* Pink - Dark Mode */ - --ds-pink-100: rgb(80 7 36); - --ds-pink-200: rgb(131 24 67); - --ds-pink-300: rgb(157 23 77); - --ds-pink-400: rgb(190 24 93); - --ds-pink-500: rgb(219 39 119); - --ds-pink-600: rgb(236 72 153); - --ds-pink-700: rgb(244 114 182); - --ds-pink-800: rgb(249 168 212); - --ds-pink-900: rgb(251 207 232); - --ds-pink-1000: rgb(252 231 243); - - /* Shadows - Dark Mode */ - --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); - --ds-shadow-medium: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 4px 8px rgba(0, 0, 0, 0.4), 0 24px 48px rgba(0, 0, 0, 0.4); - --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); - } -} - -/* Dark mode: applies when .dark class is present (overrides system preference) */ .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); /* Geist Design System Colors - Dark Mode */ /* Backgrounds */ @@ -324,17 +223,6 @@ --ds-gray-900: rgb(245 245 245); --ds-gray-1000: rgb(255 255 255); - /* Gray Alpha (with transparency) */ - --ds-gray-alpha-100: rgb(255 255 255 / 0.02); - --ds-gray-alpha-200: rgb(255 255 255 / 0.04); - --ds-gray-alpha-300: rgb(255 255 255 / 0.08); - --ds-gray-alpha-400: rgb(255 255 255 / 0.12); - --ds-gray-alpha-500: rgb(255 255 255 / 0.3); - --ds-gray-alpha-600: rgb(255 255 255 / 0.5); - --ds-gray-alpha-700: rgb(255 255 255 / 0.6); - --ds-gray-alpha-800: rgb(255 255 255 / 0.7); - --ds-gray-alpha-900: rgb(255 255 255 / 0.9); - /* Blue - Dark Mode */ --ds-blue-100: rgb(8 47 73); --ds-blue-200: rgb(12 74 110); @@ -383,18 +271,6 @@ --ds-green-900: rgb(187 247 208); --ds-green-1000: rgb(220 252 231); - /* Teal - Dark Mode */ - --ds-teal-100: rgb(4 47 46); - --ds-teal-200: rgb(19 78 74); - --ds-teal-300: rgb(17 94 89); - --ds-teal-400: rgb(15 118 110); - --ds-teal-500: rgb(13 148 136); - --ds-teal-600: rgb(20 184 166); - --ds-teal-700: rgb(45 212 191); - --ds-teal-800: rgb(94 234 212); - --ds-teal-900: rgb(153 246 228); - --ds-teal-1000: rgb(204 251 241); - /* Purple - Dark Mode */ --ds-purple-100: rgb(59 7 100); --ds-purple-200: rgb(88 28 135); @@ -407,17 +283,17 @@ --ds-purple-900: rgb(233 213 255); --ds-purple-1000: rgb(243 232 255); - /* Pink - Dark Mode */ - --ds-pink-100: rgb(80 7 36); - --ds-pink-200: rgb(131 24 67); - --ds-pink-300: rgb(157 23 77); - --ds-pink-400: rgb(190 24 93); - --ds-pink-500: rgb(219 39 119); - --ds-pink-600: rgb(236 72 153); - --ds-pink-700: rgb(244 114 182); - --ds-pink-800: rgb(249 168 212); - --ds-pink-900: rgb(251 207 232); - --ds-pink-1000: rgb(252 231 243); + /* Teal - Dark Mode */ + --ds-teal-100: rgb(4 47 46); + --ds-teal-200: rgb(19 78 74); + --ds-teal-300: rgb(17 94 89); + --ds-teal-400: rgb(15 118 110); + --ds-teal-500: rgb(13 148 136); + --ds-teal-600: rgb(20 184 166); + --ds-teal-700: rgb(45 212 191); + --ds-teal-800: rgb(94 234 212); + --ds-teal-900: rgb(153 246 228); + --ds-teal-1000: rgb(204 251 241); /* Shadows - Dark Mode */ --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); @@ -425,41 +301,137 @@ --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); } -@theme { - /* Allow Tailwind's default color palette while also defining custom colors */ - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - --color-card: hsl(var(--card)); - --color-card-foreground: hsl(var(--card-foreground)); - --color-popover: hsl(var(--popover)); - --color-popover-foreground: hsl(var(--popover-foreground)); - --color-primary: hsl(var(--primary)); - --color-primary-foreground: hsl(var(--primary-foreground)); - --color-secondary: hsl(var(--secondary)); - --color-secondary-foreground: hsl(var(--secondary-foreground)); - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - --color-accent: hsl(var(--accent)); - --color-accent-foreground: hsl(var(--accent-foreground)); - --color-destructive: hsl(var(--destructive)); - --color-destructive-foreground: hsl(var(--destructive-foreground)); - --color-border: hsl(var(--border)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); - - --radius-sm: 0.375rem; - --radius: calc(var(--radius)); - --radius-md: calc(var(--radius)); - --radius-lg: 0.75rem; - --radius-xl: 1rem; +/* System dark mode preference (when no explicit light/dark class is set) */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + + /* Geist Design System Colors - Dark Mode (same as .dark) */ + --ds-background-100: rgb(0 0 0); + --ds-background-200: rgb(10 10 10); + --ds-gray-100: rgb(17 17 17); + --ds-gray-200: rgb(23 23 23); + --ds-gray-300: rgb(41 41 41); + --ds-gray-400: rgb(64 64 64); + --ds-gray-500: rgb(115 115 115); + --ds-gray-600: rgb(163 163 163); + --ds-gray-700: rgb(212 212 212); + --ds-gray-800: rgb(229 229 229); + --ds-gray-900: rgb(245 245 245); + --ds-gray-1000: rgb(255 255 255); + --ds-blue-100: rgb(8 47 73); + --ds-blue-200: rgb(12 74 110); + --ds-blue-300: rgb(7 89 133); + --ds-blue-400: rgb(3 105 161); + --ds-blue-500: rgb(2 132 199); + --ds-blue-600: rgb(14 165 233); + --ds-blue-700: rgb(56 189 248); + --ds-blue-800: rgb(125 211 252); + --ds-blue-900: rgb(186 230 253); + --ds-blue-1000: rgb(224 242 254); + --ds-red-100: rgb(69 10 10); + --ds-red-200: rgb(127 29 29); + --ds-red-300: rgb(153 27 27); + --ds-red-400: rgb(185 28 28); + --ds-red-500: rgb(220 38 38); + --ds-red-600: rgb(239 68 68); + --ds-red-700: rgb(248 113 113); + --ds-red-800: rgb(252 165 165); + --ds-red-900: rgb(254 202 202); + --ds-red-1000: rgb(254 226 226); + --ds-amber-100: rgb(69 26 3); + --ds-amber-200: rgb(120 53 15); + --ds-amber-300: rgb(146 64 14); + --ds-amber-400: rgb(180 83 9); + --ds-amber-500: rgb(217 119 6); + --ds-amber-600: rgb(245 158 11); + --ds-amber-700: rgb(251 191 36); + --ds-amber-800: rgb(252 211 77); + --ds-amber-900: rgb(253 230 138); + --ds-amber-1000: rgb(254 243 199); + --ds-green-100: rgb(5 46 22); + --ds-green-200: rgb(20 83 45); + --ds-green-300: rgb(22 101 52); + --ds-green-400: rgb(21 128 61); + --ds-green-500: rgb(22 163 74); + --ds-green-600: rgb(34 197 94); + --ds-green-700: rgb(74 222 128); + --ds-green-800: rgb(134 239 172); + --ds-green-900: rgb(187 247 208); + --ds-green-1000: rgb(220 252 231); + --ds-purple-100: rgb(59 7 100); + --ds-purple-200: rgb(88 28 135); + --ds-purple-300: rgb(107 33 168); + --ds-purple-400: rgb(126 34 206); + --ds-purple-500: rgb(147 51 234); + --ds-purple-600: rgb(168 85 247); + --ds-purple-700: rgb(192 132 252); + --ds-purple-800: rgb(216 180 254); + --ds-purple-900: rgb(233 213 255); + --ds-purple-1000: rgb(243 232 255); + --ds-teal-100: rgb(4 47 46); + --ds-teal-200: rgb(19 78 74); + --ds-teal-300: rgb(17 94 89); + --ds-teal-400: rgb(15 118 110); + --ds-teal-500: rgb(13 148 136); + --ds-teal-600: rgb(20 184 166); + --ds-teal-700: rgb(45 212 191); + --ds-teal-800: rgb(94 234 212); + --ds-teal-900: rgb(153 246 228); + --ds-teal-1000: rgb(204 251 241); + --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); + --ds-shadow-medium: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 4px 8px rgba(0, 0, 0, 0.4), 0 24px 48px rgba(0, 0, 0, 0.4); + --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); + } } -* { - border-color: hsl(var(--border)); +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground overscroll-none; + } + html { + @apply overscroll-none; + } } -body { - background: hsl(var(--background)); - color: hsl(var(--foreground)); - font-feature-settings: "rlig" 1, "calt" 1; +@keyframes gradient { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } } diff --git a/packages/web/src/app/layout-client.tsx b/packages/web/src/app/layout-client.tsx index 451d54745..9fa15e969 100644 --- a/packages/web/src/app/layout-client.tsx +++ b/packages/web/src/app/layout-client.tsx @@ -2,11 +2,11 @@ import { TooltipProvider } from '@radix-ui/react-tooltip'; import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { ThemeProvider } from 'next-themes'; -import { useEffect } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { ThemeProvider, useTheme } from 'next-themes'; +import { useEffect, useRef } from 'react'; import { ConnectionStatus } from '@/components/display-utils/connection-status'; -import { SettingsSidebar } from '@/components/settings-sidebar'; +import { SettingsDropdown } from '@/components/settings-dropdown'; import { Toaster } from '@/components/ui/sonner'; import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; import { Logo } from '../icons/logo'; @@ -15,33 +15,49 @@ interface LayoutClientProps { children: React.ReactNode; } -export function LayoutClient({ children }: LayoutClientProps) { +function LayoutContent({ children }: LayoutClientProps) { const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const config = useQueryParamConfig(); + const { setTheme } = useTheme(); const id = searchParams.get('id'); const runId = searchParams.get('runId'); const stepId = searchParams.get('stepId'); const hookId = searchParams.get('hookId'); const resource = searchParams.get('resource'); - const theme = searchParams.get('theme') || 'system'; - - // Apply theme class to document - useEffect(() => { - const html = document.documentElement; + const themeParam = searchParams.get('theme'); - // Remove existing theme classes - html.classList.remove('light', 'dark'); + // Track if we've already handled the initial navigation + const hasNavigatedRef = useRef(false); - // Apply theme class (system will use CSS media query) - if (theme === 'light' || theme === 'dark') { - html.classList.add(theme); + // Sync theme from URL param to next-themes (one-time or when explicitly changed) + useEffect(() => { + if ( + themeParam && + (themeParam === 'light' || + themeParam === 'dark' || + themeParam === 'system') + ) { + setTheme(themeParam); } - }, [theme]); + }, [themeParam, setTheme]); // If initialized with a resource/id or direct ID params, we navigate to the appropriate page + // Only run this logic once on mount or when we're on the root path with special params useEffect(() => { + // Skip if we're not on the root path and we've already navigated + if (pathname !== '/' && hasNavigatedRef.current) { + return; + } + + // Skip if we're already on a run page (prevents interference with back navigation) + if (pathname.startsWith('/run/')) { + hasNavigatedRef.current = true; + return; + } + // Handle direct ID parameters (runId, stepId, hookId) without resource if (!resource) { if (runId) { @@ -63,6 +79,7 @@ export function LayoutClient({ children }: LayoutClientProps) { // Just open the run targetUrl = buildUrlWithConfig(`/run/${runId}`, config); } + hasNavigatedRef.current = true; router.push(targetUrl); return; } @@ -109,41 +126,49 @@ export function LayoutClient({ children }: LayoutClientProps) { return; } + hasNavigatedRef.current = true; router.push(targetUrl); - }, [resource, id, runId, stepId, hookId, router, config]); + }, [resource, id, runId, stepId, hookId, router, config, pathname]); + return ( +
+ +
+
+
+ +

+ +

+ +
+ + +
+
+
+ + {children} +
+
+ +
+ ); +} + +export function LayoutClient({ children }: LayoutClientProps) { return ( -
- -
-
-
- -

- -

- -
- - -
-
-
- - {children} -
-
- -
+ {children}
); } diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 9f31ae308..fe198b9e1 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; import { connection } from 'next/server'; +import { NuqsAdapter } from 'nuqs/adapters/next/app'; import { LayoutClient } from './layout-client'; const geistSans = Geist({ @@ -34,7 +35,9 @@ export default async function RootLayout({ - {children} + + {children} + ); diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 088d59ca2..be6c3e1e5 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,20 +1,38 @@ 'use client'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { AlertCircle } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { ErrorBoundary } from '@/components/error-boundary'; import { HooksTable } from '@/components/hooks-table'; import { RunsTable } from '@/components/runs-table'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { WorkflowsList } from '@/components/workflows-list'; import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; +import { + useHookIdState, + useSidebarState, + useTabState, + useWorkflowIdState, +} from '@/lib/url-state'; +import { useWorkflowGraphManifest } from '@/lib/use-workflow-graph'; export default function Home() { const router = useRouter(); - const searchParams = useSearchParams(); const config = useQueryParamConfig(); + const [sidebar] = useSidebarState(); + const [hookId] = useHookIdState(); + const [tab, setTab] = useTabState(); - const sidebar = searchParams.get('sidebar'); - const hookId = searchParams.get('hookId') || searchParams.get('hook'); const selectedHookId = sidebar === 'hook' && hookId ? hookId : undefined; + // Fetch workflow graph manifest + const { + manifest: graphManifest, + loading: graphLoading, + error: graphError, + } = useWorkflowGraphManifest(config); + const handleRunClick = (runId: string, streamId?: string) => { if (!streamId) { router.push(buildUrlWithConfig(`/run/${runId}`, config)); @@ -38,25 +56,58 @@ export default function Home() { } }; + const workflows = graphManifest ? Object.values(graphManifest.workflows) : []; + return (
- - - - - - - + + + Runs + Hooks + Workflows + + + + + + + + + + + + + +
+ {graphError && ( + + + Error Loading Workflows + {graphError.message} + + )} + {}} + loading={graphLoading} + /> +
+
+
+
); } diff --git a/packages/web/src/app/run/[runId]/page.tsx b/packages/web/src/app/run/[runId]/page.tsx index 803589fb8..debf597d3 100644 --- a/packages/web/src/app/run/[runId]/page.tsx +++ b/packages/web/src/app/run/[runId]/page.tsx @@ -1,26 +1,24 @@ 'use client'; -import { useParams, useSearchParams } from 'next/navigation'; +import { useParams } from 'next/navigation'; import { ErrorBoundary } from '@/components/error-boundary'; import { RunDetailView } from '@/components/run-detail-view'; import { useQueryParamConfig } from '@/lib/config'; +import { + useEventIdState, + useHookIdState, + useStepIdState, +} from '@/lib/url-state'; export default function RunDetailPage() { const params = useParams(); - const searchParams = useSearchParams(); const config = useQueryParamConfig(); + const [stepId] = useStepIdState(); + const [eventId] = useEventIdState(); + const [hookId] = useHookIdState(); const runId = params.runId as string; - const stepId = searchParams.get('stepId') || searchParams.get('step'); - const eventId = searchParams.get('eventId') || searchParams.get('event'); - const hookId = searchParams.get('hookId') || searchParams.get('hook'); - const selectedId = stepId - ? stepId - : eventId - ? eventId - : hookId - ? hookId - : undefined; + const selectedId = stepId || eventId || hookId || undefined; return ( - - {label} - - ); -} diff --git a/packages/web/src/components/display-utils/status-badge.tsx b/packages/web/src/components/display-utils/status-badge.tsx index 09fe7fe99..1cc28951c 100644 --- a/packages/web/src/components/display-utils/status-badge.tsx +++ b/packages/web/src/components/display-utils/status-badge.tsx @@ -6,6 +6,7 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; interface StatusBadgeProps { status: WorkflowRun['status'] | Step['status']; @@ -17,19 +18,19 @@ export function StatusBadge({ status, context, className }: StatusBadgeProps) { const getStatusClasses = () => { switch (status) { case 'running': - return 'text-blue-600 dark:text-blue-400'; + return 'text-blue-600 dark:text-blue-600 font-medium capitalize text-sm'; case 'completed': - return 'text-green-600 dark:text-green-400'; + return 'text-green-600 dark:text-green-600 font-medium capitalize text-sm'; case 'failed': - return 'text-red-600 dark:text-red-400'; + return 'text-red-600 dark:text-red-600 font-medium capitalize text-sm'; case 'cancelled': - return 'text-yellow-600 dark:text-yellow-400'; + return 'text-yellow-600 dark:text-yellow-600 font-medium capitalize text-sm'; case 'pending': - return 'text-gray-600 dark:text-gray-400'; + return 'text-gray-600 dark:text-gray-600 font-medium capitalize text-sm'; case 'paused': - return 'text-orange-600 dark:text-orange-400'; + return 'text-orange-600 dark:text-orange-600 font-medium capitalize text-sm'; default: - return 'text-gray-500 dark:text-gray-400'; + return 'text-gray-600 dark:text-gray-600 font-medium capitalize text-sm'; } }; @@ -46,7 +47,11 @@ export function StatusBadge({ status, context, className }: StatusBadgeProps) { {status} @@ -60,5 +65,5 @@ export function StatusBadge({ status, context, className }: StatusBadgeProps) { ); } - return {status}; + return {status}; } diff --git a/packages/web/src/components/display-utils/table-skeleton.tsx b/packages/web/src/components/display-utils/table-skeleton.tsx index 183c41562..7eb3dd00b 100644 --- a/packages/web/src/components/display-utils/table-skeleton.tsx +++ b/packages/web/src/components/display-utils/table-skeleton.tsx @@ -15,10 +15,10 @@ export function TableSkeleton({ return (
- +
{Array.from({ length: rows }, (_, i) => ( -
+
diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index dc8b228c7..b292b7c0d 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -1,7 +1,10 @@ 'use client'; -import { getErrorMessage, useWorkflowHooks } from '@workflow/web-shared'; -import { fetchEventsByCorrelationId } from '@workflow/web-shared/server'; +import { + fetchEventsByCorrelationId, + getErrorMessage, + useWorkflowHooks, +} from '@workflow/web-shared'; import type { Event, Hook } from '@workflow/world'; import { AlertCircle, @@ -209,11 +212,9 @@ export function HooksTable({ return (
-
-

- Hooks -

-
+
+
+

Last refreshed

{lastRefreshTime && ( )} +
+