Skip to content

Commit f997526

Browse files
authored
feat(init): pre-read common config files to reduce round-trips (#704)
## Summary Pre-reads ~35 common config files (manifests, framework configs, Sentry configs) after computing the directory listing, then sends them as `fileCache` in the `startAsync` input data. Workflow steps already check `fileCache` before suspending for `read-files`, so this eliminates 1-3 HTTP round-trips with **no server-side changes needed** — the `fileCache` field is already in step input schemas. Capped at 512KB total to avoid sending excessive data upfront. ### Files pre-read Manifests: `package.json`, `tsconfig.json`, `pyproject.toml`, `Gemfile`, `go.mod`, `build.gradle`, `pom.xml`, `Cargo.toml`, `pubspec.yaml`, `mix.exs`, `composer.json` Framework configs: `next.config.*`, `nuxt.config.*`, `angular.json`, `astro.config.*`, `svelte.config.js`, `remix.config.js`, `vite.config.*`, `webpack.config.js` Sentry configs: `sentry.client.config.*`, `sentry.server.config.*`, `sentry.edge.config.*`, `instrumentation.*` ## Test plan - [x] Existing tests pass (no behavioral change for existing ops) - [x] Lint clean Note: this needs `fileCache` added to `wizardInputSchema` on the server to actually flow through. It's a one-line schema change (`fileCache: z.record(z.string(), z.string().nullable()).optional()`). Without it, the server silently drops the extra field — no breakage. Made with [Cursor](https://cursor.com)
1 parent db8f92e commit f997526

4 files changed

Lines changed: 152 additions & 8 deletions

File tree

src/lib/init/local-ops.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,144 @@ export async function precomputeDirListing(
269269
return (result.data as { entries?: DirEntry[] })?.entries ?? [];
270270
}
271271

272+
/**
273+
* Common config file names that are frequently requested by multiple workflow
274+
* steps (discover-context, detect-platform, plan-codemods). Pre-reading them
275+
* eliminates 1-3 suspend/resume round-trips.
276+
*/
277+
const COMMON_CONFIG_FILES = [
278+
// ── Manifests (all ecosystems) ──
279+
"package.json",
280+
"tsconfig.json",
281+
"pyproject.toml",
282+
"requirements.txt",
283+
"requirements-dev.txt",
284+
"setup.py",
285+
"setup.cfg",
286+
"Pipfile",
287+
"Gemfile",
288+
"Gemfile.lock",
289+
"go.mod",
290+
"build.gradle",
291+
"build.gradle.kts",
292+
"settings.gradle",
293+
"settings.gradle.kts",
294+
"pom.xml",
295+
"Cargo.toml",
296+
"pubspec.yaml",
297+
"mix.exs",
298+
"composer.json",
299+
"Podfile",
300+
"CMakeLists.txt",
301+
302+
// ── JavaScript/TypeScript framework configs ──
303+
"next.config.js",
304+
"next.config.mjs",
305+
"next.config.ts",
306+
"nuxt.config.ts",
307+
"nuxt.config.js",
308+
"angular.json",
309+
"astro.config.mjs",
310+
"astro.config.ts",
311+
"svelte.config.js",
312+
"remix.config.js",
313+
"vite.config.ts",
314+
"vite.config.js",
315+
"webpack.config.js",
316+
"metro.config.js",
317+
"app.json",
318+
"electron-builder.yml",
319+
"wrangler.toml",
320+
"wrangler.jsonc",
321+
"serverless.yml",
322+
"serverless.ts",
323+
"bunfig.toml",
324+
325+
// ── Python entry points / framework markers ──
326+
"manage.py",
327+
"app.py",
328+
"main.py",
329+
330+
// ── PHP framework markers ──
331+
"artisan",
332+
"symfony.lock",
333+
"wp-config.php",
334+
"config/packages/sentry.yaml",
335+
336+
// ── .NET ──
337+
"appsettings.json",
338+
"Program.cs",
339+
"Startup.cs",
340+
341+
// ── Java / Android ──
342+
"app/build.gradle",
343+
"app/build.gradle.kts",
344+
"src/main/resources/application.properties",
345+
"src/main/resources/application.yml",
346+
347+
// ── Ruby (Rails) ──
348+
"config/application.rb",
349+
350+
// ── Go entry point ──
351+
"main.go",
352+
353+
// ── Sentry configs (all ecosystems) ──
354+
"sentry.client.config.ts",
355+
"sentry.client.config.js",
356+
"sentry.server.config.ts",
357+
"sentry.server.config.js",
358+
"sentry.edge.config.ts",
359+
"sentry.edge.config.js",
360+
"sentry.properties",
361+
"instrumentation.ts",
362+
"instrumentation.js",
363+
];
364+
365+
const MAX_PREREAD_TOTAL_BYTES = 512 * 1024;
366+
367+
/**
368+
* Pre-read common config files that exist in the directory listing.
369+
* Returns a fileCache map (path -> content or null) that the server
370+
* can use to skip read-files suspend/resume round-trips.
371+
*/
372+
export async function preReadCommonFiles(
373+
directory: string,
374+
dirListing: DirEntry[]
375+
): Promise<Record<string, string | null>> {
376+
const listingPaths = new Set(
377+
dirListing.map((e) => e.path.replaceAll("\\", "/"))
378+
);
379+
const toRead = COMMON_CONFIG_FILES.filter((f) => listingPaths.has(f));
380+
381+
const cache: Record<string, string | null> = {};
382+
let totalBytes = 0;
383+
384+
for (const filePath of toRead) {
385+
if (totalBytes >= MAX_PREREAD_TOTAL_BYTES) {
386+
break;
387+
}
388+
try {
389+
const absPath = path.join(directory, filePath);
390+
let content = await fs.promises.readFile(absPath, "utf-8");
391+
if (filePath.endsWith(".json")) {
392+
try {
393+
content = JSON.stringify(JSON.parse(content));
394+
} catch {
395+
// Not valid JSON — send as-is
396+
}
397+
}
398+
if (totalBytes + content.length <= MAX_PREREAD_TOTAL_BYTES) {
399+
cache[filePath] = content;
400+
totalBytes += content.length;
401+
}
402+
} catch {
403+
cache[filePath] = null;
404+
}
405+
}
406+
407+
return cache;
408+
}
409+
272410
export async function handleLocalOp(
273411
payload: LocalOpPayload,
274412
options: WizardOptions

src/lib/init/wizard-runner.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
detectExistingProject,
4848
handleLocalOp,
4949
precomputeDirListing,
50+
preReadCommonFiles,
5051
resolveOrgSlug,
5152
tryGetExistingProject,
5253
} from "./local-ops.js";
@@ -636,12 +637,20 @@ export async function runWizard(initialOptions: WizardOptions): Promise<void> {
636637
let result: WorkflowRunResult;
637638
try {
638639
const dirListing = await precomputeDirListing(directory);
640+
const fileCache = await preReadCommonFiles(directory, dirListing);
639641
spin.message("Connecting to wizard...");
640642
run = await workflow.createRun();
641643
result = assertWorkflowResult(
642644
await withTimeout(
643645
run.startAsync({
644-
inputData: { directory, yes, dryRun, features, dirListing },
646+
inputData: {
647+
directory,
648+
yes,
649+
dryRun,
650+
features,
651+
dirListing,
652+
fileCache,
653+
},
645654
tracingOptions,
646655
}),
647656
API_TIMEOUT_MS,

test/lib/init/local-ops.test.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,9 +1273,6 @@ describe("grep", () => {
12731273
beforeEach(() => {
12741274
testDir = mkdtempSync(join("/tmp", "grep-test-"));
12751275
options = makeOptions({ directory: testDir });
1276-
// Init a git repo so git grep / git ls-files tier is exercised
1277-
const { execSync } = require("node:child_process");
1278-
execSync("git init -q", { cwd: testDir });
12791276
writeFileSync(
12801277
join(testDir, "app.ts"),
12811278
'import * as Sentry from "@sentry/node";\nSentry.init({ dsn: "..." });\n'
@@ -1423,8 +1420,6 @@ describe("glob", () => {
14231420
beforeEach(() => {
14241421
testDir = mkdtempSync(join("/tmp", "glob-test-"));
14251422
options = makeOptions({ directory: testDir });
1426-
const { execSync } = require("node:child_process");
1427-
execSync("git init -q", { cwd: testDir });
14281423
writeFileSync(join(testDir, "app.ts"), "x");
14291424
writeFileSync(join(testDir, "utils.ts"), "x");
14301425
writeFileSync(join(testDir, "config.json"), "{}");

test/lib/init/wizard-runner.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ beforeEach(() => {
198198
ops,
199199
"precomputeDirListing"
200200
).mockResolvedValue([]);
201+
spyOn(ops, "preReadCommonFiles").mockResolvedValue({});
201202
handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({
202203
action: "continue",
203204
});
@@ -400,9 +401,10 @@ describe("runWizard", { timeout: TEST_TIMEOUT_MS }, () => {
400401
const promise = runWizard(makeOptions());
401402

402403
// Flush microtasks so runWizard reaches the withTimeout setTimeout.
403-
// preamble() → confirmExperimental() → checkGitStatus() → createRun()
404+
// preamble() → confirmExperimental() → checkGitStatus() →
405+
// precomputeDirListing() → preReadCommonFiles() → createRun()
404406
// each need a tick.
405-
for (let i = 0; i < 10; i++) await Promise.resolve();
407+
for (let i = 0; i < 20; i++) await Promise.resolve();
406408

407409
// Advance past the timeout
408410
jest.advanceTimersByTime(API_TIMEOUT_MS);

0 commit comments

Comments
 (0)