Skip to content

Commit 5313a92

Browse files
betegonclaude
andauthored
perf(init): pre-compute dir listing and send _prevPhases for cross-phase caching (#307)
## Summary Two optimizations to reduce round-trips during the init wizard: 1. **Pre-computed directory listing** — sends a pre-computed directory listing with the first API call so the server can skip its initial `list-dir` suspend. Saves one full HTTP round-trip in the `discover-context` step. 2. **`_prevPhases` for cross-phase caching** — tracks per-step result history (`stepHistory`) and sends `_prevPhases` with each resume payload. This lets the server reuse results from earlier phases (e.g. the `read-files` phase can reuse data from `analyze`) without re-requesting them. ## Changes - Exports `precomputeDirListing` from `local-ops.ts` — reuses the existing `listDir` function with the same params the server would request (recursive, maxDepth 3, maxEntries 500). The wizard runner calls it before `startAsync` and includes the result as `dirListing` in `inputData`. - Adds a `stepHistory` map to track accumulated local-op results per step. Each resume payload now includes `_prevPhases` containing results from prior phases of the same step. Companion server change: getsentry/cli-init-api#16 ## Test plan - [x] Init tests pass (`bun test test/lib/init/`) - [x] Lint passes - [ ] End-to-end with local dev server 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d1b5fc8 commit 5313a92

File tree

6 files changed

+91
-10
lines changed

6 files changed

+91
-10
lines changed

src/lib/init/local-ops.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from "./constants.js";
1616
import type {
1717
ApplyPatchsetPayload,
18+
DirEntry,
1819
FileExistsBatchPayload,
1920
ListDirPayload,
2021
LocalOpPayload,
@@ -165,6 +166,20 @@ function safePath(cwd: string, relative: string): string {
165166
return resolved;
166167
}
167168

169+
/**
170+
* Pre-compute directory listing before the first API call.
171+
* Uses the same parameters the server's discover-context step would request.
172+
*/
173+
export function precomputeDirListing(directory: string): DirEntry[] {
174+
const result = listDir({
175+
type: "local-op",
176+
operation: "list-dir",
177+
cwd: directory,
178+
params: { path: ".", recursive: true, maxDepth: 3, maxEntries: 500 },
179+
});
180+
return (result.data as { entries?: DirEntry[] })?.entries ?? [];
181+
}
182+
168183
export async function handleLocalOp(
169184
payload: LocalOpPayload,
170185
options: WizardOptions
@@ -218,11 +233,7 @@ function listDir(payload: ListDirPayload): LocalOpResult {
218233
const maxEntries = params.maxEntries ?? 500;
219234
const recursive = params.recursive ?? false;
220235

221-
const entries: Array<{
222-
name: string;
223-
path: string;
224-
type: "file" | "directory";
225-
}> = [];
236+
const entries: DirEntry[] = [];
226237

227238
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: walking the directory tree is a complex operation
228239
function walk(dir: string, depth: number): void {

src/lib/init/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
export type DirEntry = {
2+
name: string;
3+
path: string;
4+
type: "file" | "directory";
5+
};
6+
17
export type WizardOptions = {
28
directory: string;
39
force: boolean;

src/lib/init/wizard-runner.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from "./constants.js";
2222
import { formatError, formatResult } from "./formatters.js";
2323
import { handleInteractive } from "./interactive.js";
24-
import { handleLocalOp } from "./local-ops.js";
24+
import { handleLocalOp, precomputeDirListing } from "./local-ops.js";
2525
import type {
2626
InteractivePayload,
2727
LocalOpPayload,
@@ -50,7 +50,8 @@ function nextPhase(
5050

5151
async function handleSuspendedStep(
5252
ctx: StepContext,
53-
stepPhases: Map<string, number>
53+
stepPhases: Map<string, number>,
54+
stepHistory: Map<string, Record<string, unknown>[]>
5455
): Promise<Record<string, unknown>> {
5556
const { payload, stepId, spin, options } = ctx;
5657
const { type: payloadType, operation } = payload as {
@@ -65,9 +66,14 @@ async function handleSuspendedStep(
6566

6667
const localResult = await handleLocalOp(payload as LocalOpPayload, options);
6768

69+
const history = stepHistory.get(stepId) ?? [];
70+
history.push(localResult);
71+
stepHistory.set(stepId, history);
72+
6873
return {
6974
...localResult,
7075
_phase: nextPhase(stepPhases, stepId, ["read-files", "analyze", "done"]),
76+
_prevPhases: history.slice(0, -1),
7177
};
7278
}
7379

@@ -150,13 +156,16 @@ export async function runWizard(options: WizardOptions): Promise<void> {
150156

151157
const spin = spinner();
152158

159+
spin.start("Scanning project...");
160+
const dirListing = precomputeDirListing(directory);
161+
153162
let run: Awaited<ReturnType<typeof workflow.createRun>>;
154163
let result: WorkflowRunResult;
155164
try {
156-
spin.start("Connecting to wizard...");
165+
spin.message("Connecting to wizard...");
157166
run = await workflow.createRun();
158167
result = (await run.startAsync({
159-
inputData: { directory, force, yes, dryRun, features },
168+
inputData: { directory, force, yes, dryRun, features, dirListing },
160169
tracingOptions,
161170
})) as WorkflowRunResult;
162171
} catch (err) {
@@ -168,6 +177,7 @@ export async function runWizard(options: WizardOptions): Promise<void> {
168177
}
169178

170179
const stepPhases = new Map<string, number>();
180+
const stepHistory = new Map<string, Record<string, unknown>[]>();
171181

172182
try {
173183
while (result.status === "suspended") {
@@ -185,7 +195,8 @@ export async function runWizard(options: WizardOptions): Promise<void> {
185195

186196
const resumeData = await handleSuspendedStep(
187197
{ payload: extracted.payload, stepId: extracted.stepId, spin, options },
188-
stepPhases
198+
stepPhases,
199+
stepHistory
189200
);
190201

191202
result = (await run.resumeAsync({

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const mockHandleLocalOp = mock(() =>
4040
);
4141
mock.module("../../src/lib/init/local-ops.js", () => ({
4242
handleLocalOp: mockHandleLocalOp,
43+
precomputeDirListing: () => [],
4344
validateCommand: () => {
4445
/* noop mock */
4546
},

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs, { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
33
import { join } from "node:path";
44
import {
55
handleLocalOp,
6+
precomputeDirListing,
67
validateCommand,
78
} from "../../../src/lib/init/local-ops.js";
89
import type {
@@ -768,3 +769,49 @@ describe("handleLocalOp", () => {
768769
});
769770
});
770771
});
772+
773+
describe("precomputeDirListing", () => {
774+
let testDir: string;
775+
776+
beforeEach(() => {
777+
testDir = mkdtempSync(join("/tmp", "precompute-test-"));
778+
});
779+
780+
afterEach(() => {
781+
rmSync(testDir, { recursive: true, force: true });
782+
});
783+
784+
test("returns DirEntry[] directly", () => {
785+
writeFileSync(join(testDir, "app.ts"), "x");
786+
mkdirSync(join(testDir, "src"));
787+
788+
const entries = precomputeDirListing(testDir);
789+
790+
expect(Array.isArray(entries)).toBe(true);
791+
expect(entries.length).toBeGreaterThanOrEqual(2);
792+
793+
const names = entries.map((e) => e.name).sort();
794+
expect(names).toContain("app.ts");
795+
expect(names).toContain("src");
796+
797+
const file = entries.find((e) => e.name === "app.ts");
798+
expect(file?.type).toBe("file");
799+
800+
const dir = entries.find((e) => e.name === "src");
801+
expect(dir?.type).toBe("directory");
802+
});
803+
804+
test("returns empty array for non-existent directory", () => {
805+
const entries = precomputeDirListing(join(testDir, "nope"));
806+
expect(entries).toEqual([]);
807+
});
808+
809+
test("recursively lists nested entries", () => {
810+
mkdirSync(join(testDir, "a"));
811+
writeFileSync(join(testDir, "a", "nested.ts"), "x");
812+
813+
const entries = precomputeDirListing(testDir);
814+
const paths = entries.map((e) => e.path);
815+
expect(paths).toContain(join("a", "nested.ts"));
816+
});
817+
});

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ let formatBannerSpy: ReturnType<typeof spyOn>;
6666
let formatResultSpy: ReturnType<typeof spyOn>;
6767
let formatErrorSpy: ReturnType<typeof spyOn>;
6868
let handleLocalOpSpy: ReturnType<typeof spyOn>;
69+
let precomputeDirListingSpy: ReturnType<typeof spyOn>;
6970
let handleInteractiveSpy: ReturnType<typeof spyOn>;
7071

7172
// MastraClient
@@ -143,6 +144,9 @@ beforeEach(() => {
143144
ok: true,
144145
data: { results: [] },
145146
});
147+
precomputeDirListingSpy = spyOn(ops, "precomputeDirListing").mockReturnValue(
148+
[]
149+
);
146150
handleInteractiveSpy = spyOn(inter, "handleInteractive").mockResolvedValue({
147151
action: "continue",
148152
});
@@ -169,6 +173,7 @@ afterEach(() => {
169173
formatResultSpy.mockRestore();
170174
formatErrorSpy.mockRestore();
171175
handleLocalOpSpy.mockRestore();
176+
precomputeDirListingSpy.mockRestore();
172177
handleInteractiveSpy.mockRestore();
173178

174179
stderrSpy.mockRestore();

0 commit comments

Comments
 (0)