Skip to content

Commit e3f0390

Browse files
Workflows graph extractor (#455)
--------- Signed-off-by: Karthik Kalyanaraman <karthik@scale3labs.com>
1 parent b97b87b commit e3f0390

File tree

31 files changed

+5061
-806
lines changed

31 files changed

+5061
-806
lines changed

.changeset/huge-rabbits-travel.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@workflow/world-postgres": patch
3+
"@workflow/world-local": patch
4+
"@workflow/sveltekit": patch
5+
"@workflow/builders": patch
6+
"@workflow/nitro": patch
7+
"@workflow/utils": patch
8+
"@workflow/world": patch
9+
"@workflow/core": patch
10+
"@workflow/next": patch
11+
"@workflow/web": patch
12+
---
13+
14+
Added Control Flow Graph extraction from Workflows and extended manifest.json's schema to incorporate the graph structure into it. Refactored manifest generation to pass manifest as a parameter instead of using instance state. Add e2e tests for manifest validation across all builders.

packages/builders/src/base-builder.ts

Lines changed: 117 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createDiscoverEntriesPlugin } from './discover-entries-esbuild-plugin.j
1313
import { createNodeModuleErrorPlugin } from './node-module-esbuild-plugin.js';
1414
import { createSwcPlugin } from './swc-esbuild-plugin.js';
1515
import type { WorkflowConfig } from './types.js';
16+
import { extractWorkflowGraphs } from './workflows-extractor.js';
1617

1718
const enhancedResolve = promisify(enhancedResolveOriginal);
1819

@@ -280,6 +281,7 @@ export abstract class BaseBuilder {
280281
* Steps have full Node.js runtime access and handle side effects, API calls, etc.
281282
*
282283
* @param externalizeNonSteps - If true, only bundles step entry points and externalizes other code
284+
* @returns Build context (for watch mode) and the collected workflow manifest
283285
*/
284286
protected async createStepsBundle({
285287
inputFiles,
@@ -295,16 +297,17 @@ export abstract class BaseBuilder {
295297
outfile: string;
296298
format?: 'cjs' | 'esm';
297299
externalizeNonSteps?: boolean;
298-
}): Promise<esbuild.BuildContext | undefined> {
300+
}): Promise<{
301+
context: esbuild.BuildContext | undefined;
302+
manifest: WorkflowManifest;
303+
}> {
299304
// These need to handle watching for dev to scan for
300305
// new entries and changes to existing ones
301-
const { discoveredSteps: stepFiles } = await this.discoverEntries(
302-
inputFiles,
303-
dirname(outfile)
304-
);
306+
const { discoveredSteps: stepFiles, discoveredWorkflows: workflowFiles } =
307+
await this.discoverEntries(inputFiles, dirname(outfile));
305308

306309
// log the step files for debugging
307-
await this.writeDebugFile(outfile, { stepFiles });
310+
await this.writeDebugFile(outfile, { stepFiles, workflowFiles });
308311

309312
const stepsBundleStart = Date.now();
310313
const workflowManifest: WorkflowManifest = {};
@@ -326,6 +329,7 @@ export abstract class BaseBuilder {
326329

327330
const combinedStepFiles: string[] = [
328331
...stepFiles,
332+
...workflowFiles,
329333
...(resolvedBuiltInSteps
330334
? [
331335
resolvedBuiltInSteps,
@@ -337,6 +341,8 @@ export abstract class BaseBuilder {
337341

338342
// Create a virtual entry that imports all files. All step definitions
339343
// will get registered thanks to the swc transform.
344+
// We also import workflow files so their metadata is collected by the SWC plugin,
345+
// even though they'll be externalized from the final bundle.
340346
const imports = combinedStepFiles
341347
.map((file) => {
342348
// Normalize both paths to forward slashes before calling relative()
@@ -420,23 +426,14 @@ export abstract class BaseBuilder {
420426
this.logEsbuildMessages(stepsResult, 'steps bundle creation');
421427
console.log('Created steps bundle', `${Date.now() - stepsBundleStart}ms`);
422428

423-
const partialWorkflowManifest = {
424-
steps: workflowManifest.steps,
425-
};
426-
// always write to debug file
427-
await this.writeDebugFile(
428-
join(dirname(outfile), 'manifest'),
429-
partialWorkflowManifest,
430-
true
431-
);
432-
433429
// Create .gitignore in .swc directory
434430
await this.createSwcGitignore();
435431

436432
if (this.config.watch) {
437-
return esbuildCtx;
433+
return { context: esbuildCtx, manifest: workflowManifest };
438434
}
439435
await esbuildCtx.dispose();
436+
return { context: undefined, manifest: workflowManifest };
440437
}
441438

442439
/**
@@ -556,16 +553,6 @@ export abstract class BaseBuilder {
556553
`${Date.now() - bundleStartTime}ms`
557554
);
558555

559-
const partialWorkflowManifest = {
560-
workflows: workflowManifest.workflows,
561-
};
562-
563-
await this.writeDebugFile(
564-
join(dirname(outfile), 'manifest'),
565-
partialWorkflowManifest,
566-
true
567-
);
568-
569556
if (this.config.workflowManifestPath) {
570557
const resolvedPath = resolve(
571558
process.cwd(),
@@ -917,4 +904,107 @@ export const OPTIONS = handler;`;
917904
// We're intentionally silently ignoring this error - creating .gitignore isn't critical
918905
}
919906
}
907+
908+
/**
909+
* Creates a manifest JSON file containing step/workflow metadata
910+
* and graph data for visualization.
911+
*/
912+
protected async createManifest({
913+
workflowBundlePath,
914+
manifestDir,
915+
manifest,
916+
}: {
917+
workflowBundlePath: string;
918+
manifestDir: string;
919+
manifest: WorkflowManifest;
920+
}): Promise<void> {
921+
const buildStart = Date.now();
922+
console.log('Creating manifest...');
923+
924+
try {
925+
const workflowGraphs = await extractWorkflowGraphs(workflowBundlePath);
926+
927+
const steps = this.convertStepsManifest(manifest.steps);
928+
const workflows = this.convertWorkflowsManifest(
929+
manifest.workflows,
930+
workflowGraphs
931+
);
932+
933+
const output = { version: '1.0.0', steps, workflows };
934+
935+
await mkdir(manifestDir, { recursive: true });
936+
await writeFile(
937+
join(manifestDir, 'manifest.json'),
938+
JSON.stringify(output, null, 2)
939+
);
940+
941+
const stepCount = Object.values(steps).reduce(
942+
(acc, s) => acc + Object.keys(s).length,
943+
0
944+
);
945+
const workflowCount = Object.values(workflows).reduce(
946+
(acc, w) => acc + Object.keys(w).length,
947+
0
948+
);
949+
950+
console.log(
951+
`Created manifest with ${stepCount} step(s) and ${workflowCount} workflow(s)`,
952+
`${Date.now() - buildStart}ms`
953+
);
954+
} catch (error) {
955+
console.warn(
956+
'Failed to create manifest:',
957+
error instanceof Error ? error.message : String(error)
958+
);
959+
}
960+
}
961+
962+
private convertStepsManifest(
963+
steps: WorkflowManifest['steps']
964+
): Record<string, Record<string, { stepId: string }>> {
965+
const result: Record<string, Record<string, { stepId: string }>> = {};
966+
if (!steps) return result;
967+
968+
for (const [filePath, entries] of Object.entries(steps)) {
969+
result[filePath] = {};
970+
for (const [name, data] of Object.entries(entries)) {
971+
result[filePath][name] = { stepId: data.stepId };
972+
}
973+
}
974+
return result;
975+
}
976+
977+
private convertWorkflowsManifest(
978+
workflows: WorkflowManifest['workflows'],
979+
graphs: Record<
980+
string,
981+
Record<string, { graph: { nodes: any[]; edges: any[] } }>
982+
>
983+
): Record<
984+
string,
985+
Record<
986+
string,
987+
{ workflowId: string; graph: { nodes: any[]; edges: any[] } }
988+
>
989+
> {
990+
const result: Record<
991+
string,
992+
Record<
993+
string,
994+
{ workflowId: string; graph: { nodes: any[]; edges: any[] } }
995+
>
996+
> = {};
997+
if (!workflows) return result;
998+
999+
for (const [filePath, entries] of Object.entries(workflows)) {
1000+
result[filePath] = {};
1001+
for (const [name, data] of Object.entries(entries)) {
1002+
result[filePath][name] = {
1003+
workflowId: data.workflowId,
1004+
graph: graphs[filePath]?.[name]?.graph || { nodes: [], edges: [] },
1005+
};
1006+
}
1007+
}
1008+
return result;
1009+
}
9201010
}

packages/builders/src/standalone.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,21 @@ export class StandaloneBuilder extends BaseBuilder {
1818
tsBaseUrl: tsConfig.baseUrl,
1919
tsPaths: tsConfig.paths,
2020
};
21-
await this.buildStepsBundle(options);
21+
const manifest = await this.buildStepsBundle(options);
2222
await this.buildWorkflowsBundle(options);
2323
await this.buildWebhookFunction();
2424

25+
// Build unified manifest from workflow bundle
26+
const workflowBundlePath = this.resolvePath(
27+
this.config.workflowsBundlePath
28+
);
29+
const manifestDir = this.resolvePath('.well-known/workflow/v1');
30+
await this.createManifest({
31+
workflowBundlePath,
32+
manifestDir,
33+
manifest,
34+
});
35+
2536
await this.createClientLibrary();
2637
}
2738

@@ -33,18 +44,20 @@ export class StandaloneBuilder extends BaseBuilder {
3344
inputFiles: string[];
3445
tsBaseUrl?: string;
3546
tsPaths?: Record<string, string[]>;
36-
}): Promise<void> {
47+
}) {
3748
console.log('Creating steps bundle at', this.config.stepsBundlePath);
3849

3950
const stepsBundlePath = this.resolvePath(this.config.stepsBundlePath);
4051
await this.ensureDirectory(stepsBundlePath);
4152

42-
await this.createStepsBundle({
53+
const { manifest } = await this.createStepsBundle({
4354
outfile: stepsBundlePath,
4455
inputFiles,
4556
tsBaseUrl,
4657
tsPaths,
4758
});
59+
60+
return manifest;
4861
}
4962

5063
private async buildWorkflowsBundle({

packages/builders/src/vercel-build-output-api.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,19 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
2020
tsBaseUrl: tsConfig.baseUrl,
2121
tsPaths: tsConfig.paths,
2222
};
23-
await this.buildStepsFunction(options);
23+
const manifest = await this.buildStepsFunction(options);
2424
await this.buildWorkflowsFunction(options);
2525
await this.buildWebhookFunction(options);
2626
await this.createBuildOutputConfig(outputDir);
2727

28+
// Generate unified manifest
29+
const workflowBundlePath = join(workflowGeneratedDir, 'flow.func/index.js');
30+
await this.createManifest({
31+
workflowBundlePath,
32+
manifestDir: workflowGeneratedDir,
33+
manifest,
34+
});
35+
2836
await this.createClientLibrary();
2937
}
3038

@@ -38,13 +46,13 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
3846
workflowGeneratedDir: string;
3947
tsBaseUrl?: string;
4048
tsPaths?: Record<string, string[]>;
41-
}): Promise<void> {
49+
}) {
4250
console.log('Creating Vercel Build Output API steps function');
4351
const stepsFuncDir = join(workflowGeneratedDir, 'step.func');
4452
await mkdir(stepsFuncDir, { recursive: true });
4553

4654
// Create steps bundle
47-
await this.createStepsBundle({
55+
const { manifest } = await this.createStepsBundle({
4856
inputFiles,
4957
outfile: join(stepsFuncDir, 'index.js'),
5058
tsBaseUrl,
@@ -57,6 +65,8 @@ export class VercelBuildOutputAPIBuilder extends BaseBuilder {
5765
shouldAddSourcemapSupport: true,
5866
experimentalTriggers: [STEP_QUEUE_TRIGGER],
5967
});
68+
69+
return manifest;
6070
}
6171

6272
private async buildWorkflowsFunction({

0 commit comments

Comments
 (0)