Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4a1788b
Fix formatting for configuration error state
karthikscale3 Nov 22, 2025
f073014
Move hooks and runs as tabs
karthikscale3 Nov 22, 2025
82240ef
Theme updates
karthikscale3 Nov 23, 2025
ab87596
Migrate to nuqs
karthikscale3 Nov 23, 2025
1dc7df5
fix warnings
karthikscale3 Nov 23, 2025
acb109a
Minor fixes
karthikscale3 Nov 23, 2025
1bfd16d
Minor fixes
karthikscale3 Nov 23, 2025
4b4b55a
fix back button
karthikscale3 Nov 23, 2025
53dacc1
make font size small
karthikscale3 Nov 23, 2025
ef0ab99
Fix theme issue
karthikscale3 Nov 23, 2025
0af4c2d
workflow views
karthikscale3 Nov 23, 2025
e00700f
Fix graph extraction logic
karthikscale3 Nov 24, 2025
bbf3b41
Implement CFG
karthikscale3 Nov 24, 2025
e274059
Improve dashboard layout and workflow viewer
karthikscale3 Nov 24, 2025
9a583fd
Merge branch 'main' of github.com:karthikscale3/workflow into karthik…
karthikscale3 Nov 25, 2025
33237b2
refactor: move graph extraction to TypeScript post-bundle
karthikscale3 Nov 25, 2025
f5cbe81
nit: remove unneeded changes
karthikscale3 Nov 25, 2025
af497fd
Capture tools as steps
karthikscale3 Nov 25, 2025
080df95
fix
karthikscale3 Nov 25, 2025
9312eba
fix
karthikscale3 Nov 25, 2025
1aa86d8
chore: consolidate graph extraction changesets
karthikscale3 Nov 25, 2025
5b6697a
UI updates
karthikscale3 Nov 25, 2025
0e0c522
UI updates
karthikscale3 Nov 25, 2025
4972ffe
UI enhancements
karthikscale3 Nov 25, 2025
de123cd
Update graph node selectors
karthikscale3 Nov 25, 2025
1e8def6
Merge branch 'main' of github.com:karthikscale3/workflow into karthik…
karthikscale3 Nov 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/graph-step-traversal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't forget changeset for builders, next, and all the other packages when you're ready to actually ssubmit the incremental PRs for review)

'@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.

6 changes: 6 additions & 0 deletions .changeset/migrate-nuqs-url-state.md
Original file line number Diff line number Diff line change
@@ -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.

49 changes: 45 additions & 4 deletions packages/builders/src/apply-swc-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this should be a separate swc transform instead of another "mode" - or are we reusing enough code that you think it's simpler to just keep everything in one plugin? I'm worried this plugin is getting too bloated but not a v strong opinion. @ijjk got any thoughts?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm slightly inclined towards breaking it into its own swc transform since this graph generation isn't core for the runtime

also graph generation could run in parallel to the workflow/step generation in the esbuild plugin - or could even be disabled by configuration?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, good points. I was originally thinking about providing a configuration option. Also, breaking it up into its own swc transform could help keeping it separate from the core. I do not have any strong opinions on this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This becomes irrelevant now that the extraction is done on the bundle and not through SWC transform anymore. Kinda simplifies everything.

jscConfig?: {
paths?: Record<string, string[]>;
// this must be absolute path
Expand All @@ -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');
Expand Down Expand Up @@ -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,
};
}
89 changes: 88 additions & 1 deletion packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string[]>;
}): Promise<void> {
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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of doing it here, I imagine it's better to actually do it post bundling?

I'm curious how this plugin handles

  • a workflow calling a function defined in another non-step function fomr another file, which in turn calls multiples steps, and
  • workflows and steps defined inside npm packages, for example, DurableAgent from the @workflow/ai package

I always imagined that swc could be used to emit some meadata comments with segments of the graph, but the final manifest generation would happen in an esbuild plugin that happens after the swc transform

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was considering doing it post bundling initially. But, we would lose the capability to provide realtime updates to the graph on the dashboard during development. Also, it get a bit tricky to identify workflow and step boundaries from a bundle, as the directives are already transformed and removed at that stage. Having said that, I am still going to do a spike task to explore this path for a bit.

Re: these two scenarios:

  • a workflow calling a function defined in another non-step function from another file, which in turn calls multiples steps, and

So, not at the moment. The graph traversal is shallow right now. And I am aware of the downsides to it and this is one of them. I am incrementally improving the traversal algorithm to go deeper to cover all possible scenarios - which is also the reason why I am going back to doing this post bundling cos then we know exactly what workflows and steps are pulled in post transformation. I will have a better answer to this question in a day or two.

  • workflows and steps defined inside npm packages, for example, DurableAgent from the @workflow/ai package

Great point! I had not considered this. So I did a small experiment and realized that the DurableAgent exported from the package also transforms with in the project where it's imported and consumed. This is great, cos this means the graph generation does discover the workflow defined in it.

But, it still has the same limitation when it comes to discovering the steps - because of the shallow traversal we have. So in essence, it discovers and shows the workflows of the imported package, but, it won't show the steps yet. Let me think through this further.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, some updates:
I actually went with the post bundling approach and completely scrapped the graph mode transformation. You were right and great call on this!

It started getting rather too complex to extract the structure for the scenarios you suggested. On the bright side the post bundling approach extracts everything accurately since we operate just on a single file.

This makes the changes quite simple and most of the PR is now focused on UI updates and enhancements to the web project(o11y dashboard).

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`
);
}
}
21 changes: 21 additions & 0 deletions packages/builders/src/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -76,4 +77,24 @@ export class StandaloneBuilder extends BaseBuilder {
outfile: webhookBundlePath,
});
}

private async buildGraphManifest({
inputFiles,
tsPaths,
tsBaseUrl,
}: {
inputFiles: string[];
tsBaseUrl?: string;
tsPaths?: Record<string, string[]>;
}): Promise<void> {
const graphManifestPath = this.resolvePath('.swc/graph-manifest.json');
await this.ensureDirectory(graphManifestPath);

await this.createGraphManifest({
inputFiles,
outfile: graphManifestPath,
tsBaseUrl,
tsPaths,
});
}
}
48 changes: 48 additions & 0 deletions packages/next/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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) =>
Expand Down
Loading
Loading