Skip to content

Commit 809339b

Browse files
authored
fix(builders): enable directive discovery in dot-prefixed files and directories (#1228)
* fix(builders): enable directive discovery in dot-prefixed files and directories The glob-based file scanning in getInputFiles() was using tinyglobby's default behavior which skips dot-prefixed files/directories. This prevented discovering files with 'use step' / 'use workflow' directives inside paths like .config/step.ts or .hidden-workflow.ts. Switch to relative glob patterns with per-directory cwd and dot: true to ensure dot-files are scanned while still respecting the explicit ignore list (.git, .next, .vercel, etc.). * test: add E2E tests for dot-directory directive discovery Add workbench fixtures and E2E tests verifying that 'use step' / 'use workflow' directives inside dot-prefixed directories (.well-known/agent/) are correctly discovered, included in manifests, and executed at runtime. * fix: normalize paths in getInputFiles tests for Windows compatibility tinyglobby returns forward-slash paths even on Windows, while Node's path.join() uses backslashes. Normalize both sides to forward slashes before comparison. * refactor: use dirname() instead of join(.., '..') in test helper Cleaner and more idiomatic way to get the parent directory. * fix: add .nuxt and other build tool dot-directories to ignore list With dot: true enabled, framework build output directories like .nuxt/ are now traversed and their generated files picked up as input files. This caused a circular dependency in the nuxt builder where generated .nuxt/workflow/steps.mjs was being re-discovered as an input file. Add .nuxt, .turbo, .cache, .yarn, and .pnpm-store to the ignore list, matching the directories already ignored by the eager builder's watcher.
1 parent 42361d6 commit 809339b

9 files changed

Lines changed: 342 additions & 22 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/builders": patch
3+
---
4+
5+
Enable directive discovery in dot-prefixed files and directories (e.g. `.config/step.ts`, `.hidden-workflow.ts`)

packages/builders/src/base-builder.ts

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -97,27 +97,38 @@ export abstract class BaseBuilder {
9797
* and dependency directories.
9898
*/
9999
protected async getInputFiles(): Promise<string[]> {
100-
const patterns = this.config.dirs.map((dir) => {
101-
const resolvedDir = resolve(this.config.workingDir, dir);
102-
// Normalize path separators to forward slashes for glob compatibility
103-
const normalizedDir = resolvedDir.replace(/\\/g, '/');
104-
return `${normalizedDir}/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`;
105-
});
106-
107-
const result = await glob(patterns, {
108-
ignore: [
109-
'**/node_modules/**',
110-
'**/.git/**',
111-
'**/.next/**',
112-
'**/.vercel/**',
113-
'**/.workflow-data/**',
114-
'**/.well-known/workflow/**',
115-
'**/.svelte-kit/**',
116-
],
117-
absolute: true,
118-
});
100+
const ignore = [
101+
'**/node_modules/**',
102+
'**/.git/**',
103+
'**/.next/**',
104+
'**/.nuxt/**',
105+
'**/.vercel/**',
106+
'**/.workflow-data/**',
107+
'**/.well-known/workflow/**',
108+
'**/.svelte-kit/**',
109+
'**/.turbo/**',
110+
'**/.cache/**',
111+
'**/.yarn/**',
112+
'**/.pnpm-store/**',
113+
];
114+
115+
// Use relative patterns with `cwd` per directory so that `dot: true`
116+
// applies consistently to both the search pattern *and* the ignore
117+
// patterns. When absolute patterns are used with tinyglobby, the `**`
118+
// in ignore patterns does not match dot-prefixed path segments.
119+
const results = await Promise.all(
120+
this.config.dirs.map((dir) => {
121+
const cwd = resolve(this.config.workingDir, dir);
122+
return glob(['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], {
123+
cwd,
124+
ignore,
125+
absolute: true,
126+
dot: true,
127+
});
128+
})
129+
);
119130

120-
return result;
131+
return results.flat();
121132
}
122133

123134
/**
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import {
2+
mkdirSync,
3+
mkdtempSync,
4+
realpathSync,
5+
rmSync,
6+
writeFileSync,
7+
} from 'node:fs';
8+
import { tmpdir } from 'node:os';
9+
import { dirname, join } from 'node:path';
10+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
11+
import { BaseBuilder } from './base-builder.js';
12+
import type { StandaloneConfig } from './types.js';
13+
14+
/**
15+
* Minimal subclass to expose the protected `getInputFiles()` for testing.
16+
*/
17+
class TestBuilder extends BaseBuilder {
18+
async build(): Promise<void> {
19+
// no-op
20+
}
21+
22+
// Expose for tests
23+
public getInputFiles(): Promise<string[]> {
24+
return super.getInputFiles();
25+
}
26+
}
27+
28+
// Resolve symlinks in tmpdir to avoid macOS /var -> /private/var issues
29+
const realTmpdir = realpathSync(tmpdir());
30+
31+
/**
32+
* Normalize a path to forward slashes for cross-platform comparison.
33+
* tinyglobby always returns forward-slash paths, even on Windows,
34+
* while Node's `path.join()` uses backslashes on Windows.
35+
*/
36+
function normalize(p: string): string {
37+
return p.replace(/\\/g, '/');
38+
}
39+
40+
function writeFile(dir: string, relativePath: string, content = ''): string {
41+
const fullPath = join(dir, relativePath);
42+
mkdirSync(dirname(fullPath), { recursive: true });
43+
writeFileSync(fullPath, content, 'utf-8');
44+
return fullPath;
45+
}
46+
47+
function createBuilder(workingDir: string, dirs: string[]): TestBuilder {
48+
const config: StandaloneConfig = {
49+
buildTarget: 'standalone',
50+
workingDir,
51+
dirs,
52+
stepsBundlePath: join(workingDir, 'steps.js'),
53+
workflowsBundlePath: join(workingDir, 'workflows.js'),
54+
webhookBundlePath: join(workingDir, 'webhook.js'),
55+
};
56+
return new TestBuilder(config);
57+
}
58+
59+
describe('getInputFiles', () => {
60+
let testRoot: string;
61+
62+
beforeEach(() => {
63+
testRoot = mkdtempSync(join(realTmpdir, 'get-input-files-'));
64+
});
65+
66+
afterEach(() => {
67+
rmSync(testRoot, { recursive: true, force: true });
68+
});
69+
70+
it('discovers files inside dot-prefixed directories', async () => {
71+
const srcDir = join(testRoot, 'src');
72+
writeFile(srcDir, '.hidden/step.ts', "'use step';");
73+
writeFile(srcDir, '.config/workflow.ts', "'use workflow';");
74+
writeFile(srcDir, 'regular/step.ts', "'use step';");
75+
76+
const builder = createBuilder(testRoot, ['src']);
77+
const files = (await builder.getInputFiles()).map(normalize);
78+
79+
expect(files).toContain(normalize(join(srcDir, '.hidden/step.ts')));
80+
expect(files).toContain(normalize(join(srcDir, '.config/workflow.ts')));
81+
expect(files).toContain(normalize(join(srcDir, 'regular/step.ts')));
82+
});
83+
84+
it('discovers dot-prefixed files', async () => {
85+
const srcDir = join(testRoot, 'src');
86+
writeFile(srcDir, '.hidden-step.ts', "'use step';");
87+
writeFile(srcDir, 'visible-step.ts', "'use step';");
88+
89+
const builder = createBuilder(testRoot, ['src']);
90+
const files = (await builder.getInputFiles()).map(normalize);
91+
92+
expect(files).toContain(normalize(join(srcDir, '.hidden-step.ts')));
93+
expect(files).toContain(normalize(join(srcDir, 'visible-step.ts')));
94+
});
95+
96+
it('still excludes explicitly ignored dot-directories', async () => {
97+
const srcDir = join(testRoot, 'src');
98+
writeFile(srcDir, '.git/hooks/pre-commit.ts');
99+
writeFile(srcDir, '.next/server/page.ts');
100+
writeFile(srcDir, '.nuxt/workflow/steps.mjs');
101+
writeFile(srcDir, '.vercel/output/step.ts');
102+
writeFile(srcDir, '.svelte-kit/output/step.ts');
103+
writeFile(srcDir, '.workflow-data/state.ts');
104+
writeFile(srcDir, '.well-known/workflow/route.ts');
105+
writeFile(srcDir, '.turbo/cache/build.ts');
106+
writeFile(srcDir, '.cache/babel/plugin.js');
107+
writeFile(srcDir, '.yarn/releases/yarn.cjs');
108+
writeFile(srcDir, '.pnpm-store/v3/files.ts');
109+
writeFile(srcDir, 'node_modules/pkg/index.ts');
110+
// This one should still be found
111+
writeFile(srcDir, '.custom/step.ts', "'use step';");
112+
113+
const builder = createBuilder(testRoot, ['src']);
114+
const files = (await builder.getInputFiles()).map(normalize);
115+
116+
expect(files).not.toContain(
117+
normalize(join(srcDir, '.git/hooks/pre-commit.ts'))
118+
);
119+
expect(files).not.toContain(
120+
normalize(join(srcDir, '.next/server/page.ts'))
121+
);
122+
expect(files).not.toContain(
123+
normalize(join(srcDir, '.nuxt/workflow/steps.mjs'))
124+
);
125+
expect(files).not.toContain(
126+
normalize(join(srcDir, '.vercel/output/step.ts'))
127+
);
128+
expect(files).not.toContain(
129+
normalize(join(srcDir, '.svelte-kit/output/step.ts'))
130+
);
131+
expect(files).not.toContain(
132+
normalize(join(srcDir, '.workflow-data/state.ts'))
133+
);
134+
expect(files).not.toContain(
135+
normalize(join(srcDir, '.well-known/workflow/route.ts'))
136+
);
137+
expect(files).not.toContain(
138+
normalize(join(srcDir, '.turbo/cache/build.ts'))
139+
);
140+
expect(files).not.toContain(
141+
normalize(join(srcDir, '.cache/babel/plugin.js'))
142+
);
143+
expect(files).not.toContain(
144+
normalize(join(srcDir, '.yarn/releases/yarn.cjs'))
145+
);
146+
expect(files).not.toContain(
147+
normalize(join(srcDir, '.pnpm-store/v3/files.ts'))
148+
);
149+
expect(files).not.toContain(
150+
normalize(join(srcDir, 'node_modules/pkg/index.ts'))
151+
);
152+
expect(files).toContain(normalize(join(srcDir, '.custom/step.ts')));
153+
});
154+
155+
it('discovers files with various supported extensions in dot-directories', async () => {
156+
const srcDir = join(testRoot, 'src');
157+
writeFile(srcDir, '.api/route.tsx');
158+
writeFile(srcDir, '.api/handler.mts');
159+
writeFile(srcDir, '.api/utils.js');
160+
writeFile(srcDir, '.api/config.cjs');
161+
162+
const builder = createBuilder(testRoot, ['src']);
163+
const files = (await builder.getInputFiles()).map(normalize);
164+
165+
expect(files).toContain(normalize(join(srcDir, '.api/route.tsx')));
166+
expect(files).toContain(normalize(join(srcDir, '.api/handler.mts')));
167+
expect(files).toContain(normalize(join(srcDir, '.api/utils.js')));
168+
expect(files).toContain(normalize(join(srcDir, '.api/config.cjs')));
169+
});
170+
});

packages/core/e2e/e2e.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,28 @@ describe('e2e', () => {
263263
);
264264
});
265265

266+
// Test that "use step" / "use workflow" functions inside dot-prefixed
267+
// directories like `.well-known/agent/` are discovered and executed correctly.
268+
// Only runs on Next.js workbenches where the test file is placed.
269+
const isNextApp = process.env.APP_NAME?.includes('nextjs');
270+
test.skipIf(!isNextApp)(
271+
'wellKnownAgentWorkflow (.well-known/agent)',
272+
{ timeout: 60_000 },
273+
async () => {
274+
const run = await start(
275+
await getWorkflowMetadata(
276+
'app/.well-known/agent/v1/steps.ts',
277+
'wellKnownAgentWorkflow'
278+
),
279+
[5]
280+
);
281+
282+
const returnValue = await run.returnValue;
283+
// wellKnownAgentStep(5) => 5 * 2 = 10, then workflow adds 1 => 11
284+
expect(returnValue).toBe(11);
285+
}
286+
);
287+
266288
const isNext = process.env.APP_NAME?.includes('nextjs');
267289
const isLocal = deploymentUrl.includes('localhost');
268290
// only works with framework that transpiles react and

packages/core/e2e/manifest.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,76 @@ function getStepNodes(graph: ManifestWorkflow['graph']): ManifestNode[] {
182182
return graph.nodes.filter((n) => n.data.stepId);
183183
}
184184

185+
/**
186+
* Tests that steps and workflows inside dot-prefixed directories like
187+
* `.well-known/agent/` are correctly discovered and included in the manifest.
188+
* This verifies the fix for tinyglobby's `dot: true` option.
189+
*/
190+
describe.each(['nextjs-webpack', 'nextjs-turbopack'])(
191+
'dot-directory discovery (.well-known/agent)',
192+
(project) => {
193+
test(
194+
`${project}: discovers steps inside .well-known/agent directory`,
195+
{ timeout: 30_000 },
196+
async () => {
197+
if (process.env.APP_NAME && project !== process.env.APP_NAME) {
198+
return;
199+
}
200+
201+
const manifest = await tryReadManifest(project);
202+
if (!manifest) return;
203+
204+
// Find the step from .well-known/agent/v1/steps.ts
205+
const stepFiles = Object.keys(manifest.steps);
206+
const wellKnownStepFile = stepFiles.find(
207+
(f) =>
208+
f.includes('.well-known/agent') || f.includes('well-known/agent')
209+
);
210+
expect(
211+
wellKnownStepFile,
212+
`Expected a step file matching ".well-known/agent" in manifest steps. Available: ${stepFiles.join(', ')}`
213+
).toBeDefined();
214+
215+
const fileSteps = manifest.steps[wellKnownStepFile!];
216+
expect(fileSteps.wellKnownAgentStep).toBeDefined();
217+
expect(fileSteps.wellKnownAgentStep.stepId).toContain(
218+
'wellKnownAgentStep'
219+
);
220+
}
221+
);
222+
223+
test(
224+
`${project}: discovers workflows inside .well-known/agent directory`,
225+
{ timeout: 30_000 },
226+
async () => {
227+
if (process.env.APP_NAME && project !== process.env.APP_NAME) {
228+
return;
229+
}
230+
231+
const manifest = await tryReadManifest(project);
232+
if (!manifest) return;
233+
234+
// Find the workflow from .well-known/agent/v1/steps.ts
235+
const workflowFiles = Object.keys(manifest.workflows);
236+
const wellKnownWorkflowFile = workflowFiles.find(
237+
(f) =>
238+
f.includes('.well-known/agent') || f.includes('well-known/agent')
239+
);
240+
expect(
241+
wellKnownWorkflowFile,
242+
`Expected a workflow file matching ".well-known/agent" in manifest workflows. Available: ${workflowFiles.join(', ')}`
243+
).toBeDefined();
244+
245+
const fileWorkflows = manifest.workflows[wellKnownWorkflowFile!];
246+
expect(fileWorkflows.wellKnownAgentWorkflow).toBeDefined();
247+
expect(fileWorkflows.wellKnownAgentWorkflow.workflowId).toContain(
248+
'wellKnownAgentWorkflow'
249+
);
250+
}
251+
);
252+
}
253+
);
254+
185255
/**
186256
* Tests for single-statement control flow extraction.
187257
* These verify that steps inside if/while/for without braces are extracted.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Test file for verifying that workflow discovers step/workflow functions
3+
* inside dot-prefixed directories like `.well-known/agent/`.
4+
*
5+
* This simulates the pattern used by agent frameworks that place generated
6+
* step functions inside `app/.well-known/agent/v1/steps.ts`.
7+
*/
8+
9+
export async function wellKnownAgentStep(input: number) {
10+
'use step';
11+
return input * 2;
12+
}
13+
14+
export async function wellKnownAgentWorkflow(value: number) {
15+
'use workflow';
16+
const result = await wellKnownAgentStep(value);
17+
return result + 1;
18+
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
// THIS FILE IS JUST FOR TESTING HMR AS AN ENTRY NEEDS
22
// TO IMPORT THE WORKFLOWS TO DISCOVER THEM AND WATCH
3+
4+
// Test that steps inside dot-prefixed directories are discovered
5+
import * as wellKnownAgentSteps from '@/app/.well-known/agent/v1/steps';
36
import * as workflows from '@/workflows/3_streams';
47

58
export async function POST(_req: Request) {
6-
console.log(workflows);
9+
console.log(workflows, wellKnownAgentSteps);
710
return Response.json('hello world');
811
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Test file for verifying that workflow discovers step/workflow functions
3+
* inside dot-prefixed directories like `.well-known/agent/`.
4+
*
5+
* This simulates the pattern used by agent frameworks that place generated
6+
* step functions inside `app/.well-known/agent/v1/steps.ts`.
7+
*/
8+
9+
export async function wellKnownAgentStep(input: number) {
10+
'use step';
11+
return input * 2;
12+
}
13+
14+
export async function wellKnownAgentWorkflow(value: number) {
15+
'use workflow';
16+
const result = await wellKnownAgentStep(value);
17+
return result + 1;
18+
}

0 commit comments

Comments
 (0)