Skip to content

Commit 2dadb47

Browse files
committed
perf(cli): load the Maestro engine only when a replay entry is a flow
The command registry evaluates every command family on CLI startup, so the replay script-source builder's static @agent-device/maestro import put the YAML parser on the --help path. It now loads on demand behind the format check, and the startup import-closure guard covers the engine the way it already covers node:http.
1 parent 2ca275a commit 2dadb47

20 files changed

Lines changed: 286 additions & 147 deletions

src/__tests__/cli-startup-import-closure.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import { parseSync } from 'oxc-parser';
1212
* socket). The HTTP daemon transport, remote-artifact upload and download all
1313
* load these modules on demand instead.
1414
*
15+
* `@agent-device/maestro` is held to the same line for the same reason. The command registry
16+
* evaluates every command family's module on startup -- `--help` included -- so a replay-side
17+
* static import of the Maestro engine (and the YAML parser behind it) put a 131 kB chunk and
18+
* ~28ms on every warm command, for a format most invocations never touch (#1802). The replay
19+
* script-source builder loads it on demand, only once an entry actually resolves to a flow.
20+
*
1521
* "On demand" is a claim about SCOPE, not about syntax, which is why this reads
1622
* the AST rather than matching import forms. Two shapes evaluate the module
1723
* during module evaluation while looking lazy or looking like nothing at all:
@@ -29,6 +35,8 @@ import { parseSync } from 'oxc-parser';
2935

3036
const srcRoot = path.resolve(import.meta.dirname, '..');
3137
const LAZY_HTTP_MODULES = new Set(['node:http', 'node:https']);
38+
/** Engines heavy enough that evaluating them on startup is a measurable regression. */
39+
const LAZY_ENGINE_MODULES = new Set(['@agent-device/maestro']);
3240
const FUNCTION_NODES = new Set([
3341
'FunctionDeclaration',
3442
'FunctionExpression',
@@ -259,6 +267,24 @@ test('the CLI startup import closure never evaluates node:http or node:https', (
259267
).toEqual([]);
260268
});
261269

270+
test('the CLI startup import closure never evaluates the Maestro engine', () => {
271+
const offenders: string[] = [];
272+
for (const file of eagerClosureOfCli()) {
273+
for (const specifier of eagerlyEvaluatedModules(file, fs.readFileSync(file, 'utf8'))) {
274+
if (LAZY_ENGINE_MODULES.has(specifier)) {
275+
offenders.push(`${path.relative(srcRoot, file)} -> ${specifier}`);
276+
}
277+
}
278+
}
279+
280+
expect(
281+
offenders,
282+
'Load @agent-device/maestro on demand instead: the command registry evaluates every command ' +
283+
'family on startup, so importing the engine here costs every warm CLI command ~28ms of ' +
284+
'YAML-parser evaluation and a 131 kB startup chunk, for a format most runs never use.',
285+
).toEqual([]);
286+
});
287+
262288
test('the CLI startup import closure is reachable and crosses the package boundary', () => {
263289
// Guards the test above from silently passing because the walk found nothing:
264290
// a resolver that returned null for everything would leave both the src side
Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,30 @@
11
import type { ReplayScriptSourceBundle } from '@agent-device/contracts/replay';
22
import type { MaestroSourceReader } from '@agent-device/maestro';
33
import type { DaemonRequest } from '../../daemon/types.ts';
4-
import { buildReplayScriptSourceBundle } from '../../replay/script-source-bundle.ts';
4+
import {
5+
loadReplayScriptSourceBundle,
6+
readAdScriptSourceBundle,
7+
} from '../../replay/script-source-bundle.ts';
58
import { discoverReplaySourcePaths } from '../../replay/source-discovery.ts';
69

710
/**
8-
* Builds a replay script source bundle from a file on disk the way a real
9-
* caller does (#1802) — through the client's own builder, so a test never
10-
* hand-rolls a bundle shape the CLI would not actually send.
11+
* A native `.ad` script's bundle, built from a file on disk through the client's own reader
12+
* (#1802) so a test never hand-rolls a shape the CLI would not actually send. Synchronous
13+
* because an `.ad` script has no include grammar and needs no engine; use
14+
* `maestroScriptSourceBundleFor` for a flow.
1115
*/
12-
export function replayScriptSourceBundleFor(
16+
export function replayScriptSourceBundleFor(filePath: string): ReplayScriptSourceBundle {
17+
return readAdScriptSourceBundle({ inputPath: filePath, cwd: process.cwd() });
18+
}
19+
20+
/** A Maestro flow's bundle — async because the engine that walks its includes loads on demand. */
21+
export async function maestroScriptSourceBundleFor(
1322
filePath: string,
14-
options: { replayBackend?: string } = {},
15-
): ReplayScriptSourceBundle {
16-
return buildReplayScriptSourceBundle({
23+
): Promise<ReplayScriptSourceBundle> {
24+
return await loadReplayScriptSourceBundle({
1725
inputPath: filePath,
1826
cwd: process.cwd(),
19-
replayBackend: options.replayBackend,
27+
replayBackend: 'maestro',
2028
});
2129
}
2230

@@ -35,36 +43,44 @@ export const noMaestroIncludeSources: MaestroSourceReader = (resolvedPath) => {
3543
* Test harnesses that stand in for "a request as it arrives at the daemon" call this so their
3644
* cases keep expressing what they are about; a request that already carries its own sources (or
3745
* deliberately carries none, to pin the daemon's refusal) is returned untouched.
46+
*
47+
* Collection that cannot succeed — a case driving `replay` at a path that deliberately does not
48+
* exist — leaves the request alone rather than throwing: the real client raises that failure at
49+
* the CLI boundary (pinned in `cli-client-commands.test.ts`), and a daemon-level harness must
50+
* still deliver the request so the daemon's own response is what the case observes.
3851
*/
39-
export function withClientReplayScriptSources(req: DaemonRequest): DaemonRequest {
52+
export async function withClientReplayScriptSources(req: DaemonRequest): Promise<DaemonRequest> {
4053
if (req.command !== 'replay' && req.command !== 'test') return req;
4154
const inputs = req.positionals ?? [];
42-
if (inputs.length === 0) return req;
55+
const entryPath = inputs[0];
56+
if (entryPath === undefined) return req;
4357
const cwd = req.meta?.cwd ?? process.cwd();
4458
const replayBackend = req.flags?.replayBackend;
45-
const entryPath = inputs[0];
46-
if (req.command === 'replay') {
47-
if (req.flags?.replayScriptSource || entryPath === undefined) return req;
48-
return {
49-
...req,
50-
flags: {
51-
...(req.flags ?? {}),
52-
replayScriptSource: buildReplayScriptSourceBundle({
59+
try {
60+
if (req.command === 'replay') {
61+
if (req.flags?.replayScriptSource) return req;
62+
return withFlags(req, {
63+
replayScriptSource: await loadReplayScriptSourceBundle({
5364
inputPath: entryPath,
5465
cwd,
5566
replayBackend,
5667
}),
57-
},
58-
};
59-
}
60-
if (req.flags?.replayScriptSources) return req;
61-
return {
62-
...req,
63-
flags: {
64-
...(req.flags ?? {}),
65-
replayScriptSources: discoverReplaySourcePaths({ inputs, cwd, replayBackend }).map(
66-
(inputPath) => buildReplayScriptSourceBundle({ inputPath, cwd, replayBackend }),
68+
});
69+
}
70+
if (req.flags?.replayScriptSources) return req;
71+
return withFlags(req, {
72+
replayScriptSources: await Promise.all(
73+
discoverReplaySourcePaths({ inputs, cwd, replayBackend }).map(
74+
async (inputPath) =>
75+
await loadReplayScriptSourceBundle({ inputPath, cwd, replayBackend }),
76+
),
6777
),
68-
},
69-
};
78+
});
79+
} catch {
80+
return req;
81+
}
82+
}
83+
84+
function withFlags(req: DaemonRequest, flags: Partial<DaemonRequest['flags']>): DaemonRequest {
85+
return { ...req, flags: { ...(req.flags ?? {}), ...flags } };
7086
}

src/agent-device-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export function createAgentDeviceClient(
131131
command: DaemonCommandName,
132132
options: InternalRequestOptions = {},
133133
): Promise<T> => {
134-
const request = prepareDaemonCommandRequest(command, options);
134+
const request = await prepareDaemonCommandRequest(command, options);
135135
return (await execute(
136136
request.command,
137137
request.positionals,

src/commands/batch/projection.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import {
1111
} from '@agent-device/contracts/command';
1212
import { AppError } from '@agent-device/kernel/errors';
1313
import { request } from '../cli-grammar/common.ts';
14-
import type { CommandInput, DaemonCommandRequest, DaemonWriter } from '../cli-grammar/types.ts';
14+
import type {
15+
AsyncDaemonWriter,
16+
CommandInput,
17+
DaemonCommandRequest,
18+
} from '../cli-grammar/types.ts';
1519
import { buildRequestFlags } from '../command-flags.ts';
1620
import type { DaemonCommandName } from '../command-projection.ts';
1721

@@ -23,42 +27,46 @@ type PrepareDaemonCommandRequest = (
2327
command: string,
2428
input: CommandInput,
2529
stepNumber: number,
26-
) => DaemonCommandRequest;
30+
) => Promise<DaemonCommandRequest>;
2731

2832
export function createBatchDaemonWriter(
2933
prepareDaemonCommandRequest: PrepareDaemonCommandRequest,
30-
): DaemonWriter {
31-
return (input) =>
34+
): AsyncDaemonWriter {
35+
return async (input) =>
3236
request(PUBLIC_COMMANDS.batch, [], {
3337
...input,
34-
batchSteps: readBatchDaemonSteps(input.steps, prepareDaemonCommandRequest),
38+
batchSteps: await readBatchDaemonSteps(input.steps, prepareDaemonCommandRequest),
3539
batchOnError: input.onError,
3640
batchMaxSteps: input.maxSteps,
3741
});
3842
}
3943

40-
function readBatchDaemonSteps(
44+
async function readBatchDaemonSteps(
4145
steps: unknown,
4246
prepareDaemonCommandRequest: PrepareDaemonCommandRequest,
43-
): DaemonBatchStep[] {
47+
): Promise<DaemonBatchStep[]> {
4448
if (!Array.isArray(steps) || steps.length === 0) {
4549
throw new AppError('INVALID_ARGS', 'batch requires a non-empty steps array.');
4650
}
47-
return steps.map((step, index) =>
48-
readBatchDaemonStep(step, index + 1, prepareDaemonCommandRequest),
49-
);
51+
// Sequential, not `Promise.all`: a step's rejection is reported with its own step number, and
52+
// preparing step N+1 after N keeps that number the first failure a caller sees.
53+
const prepared: DaemonBatchStep[] = [];
54+
for (const [index, step] of steps.entries()) {
55+
prepared.push(await readBatchDaemonStep(step, index + 1, prepareDaemonCommandRequest));
56+
}
57+
return prepared;
5058
}
5159

52-
function readBatchDaemonStep(
60+
async function readBatchDaemonStep(
5361
step: unknown,
5462
stepNumber: number,
5563
prepareDaemonCommandRequest: PrepareDaemonCommandRequest,
56-
): DaemonBatchStep {
64+
): Promise<DaemonBatchStep> {
5765
const record = readBatchStepRecord(step, stepNumber);
5866
const command = readBatchStepCommand(record, stepNumber);
5967
const input = readBatchStepInputObject(record, stepNumber) as CommandInput;
6068
const runtime = parseBatchStepRuntime(record.runtime, stepNumber);
61-
const prepared = prepareDaemonCommandRequest(command, input, stepNumber);
69+
const prepared = await prepareDaemonCommandRequest(command, input, stepNumber);
6270
return {
6371
command: prepared.command,
6472
positionals: prepared.positionals,

src/commands/cli-grammar/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,15 @@ export type SelectionOptions = {
8585

8686
export type CliInput = Record<string, unknown>;
8787
export type CliReader = (positionals: string[], flags: CliFlags) => CliInput;
88+
/** Builds the daemon request for one command. Most writers are pure projections of their input. */
8889
export type DaemonWriter = (input: CommandInput) => DaemonCommandRequest;
90+
91+
/**
92+
* A writer that has caller-side work to do before the request exists: reading the replay script
93+
* files the request carries (#1802), which for a Maestro flow also loads the engine that walks its
94+
* `runFlow` includes on demand. The registry awaits whichever kind a command declares, so the
95+
* asynchrony stays inside the one writer that needs it.
96+
*/
97+
export type AsyncDaemonWriter = (input: CommandInput) => Promise<DaemonCommandRequest>;
98+
99+
export type AnyDaemonWriter = DaemonWriter | AsyncDaemonWriter;

src/commands/command-projection.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
import { createBatchDaemonWriter } from './batch/index.ts';
2-
import type { CommandInput, DaemonCommandRequest, DaemonWriter } from './cli-grammar/types.ts';
2+
import type { AnyDaemonWriter, CommandInput, DaemonCommandRequest } from './cli-grammar/types.ts';
33
import { findCommandMetadata } from './command-metadata.ts';
44
import { readMetadataCommandFlags } from './command-flags.ts';
55
import { listCommandFamilyDaemonWriters } from './family/registry.ts';
66
import { AppError } from '@agent-device/kernel/errors';
77

8-
const daemonWriters: Record<string, DaemonWriter> = {
8+
const daemonWriters: Record<string, AnyDaemonWriter> = {
99
...listCommandFamilyDaemonWriters(),
1010
batch: createBatchDaemonWriter(prepareBatchDaemonCommandRequest),
1111
};
1212

1313
export type DaemonCommandName = keyof typeof daemonWriters;
1414

15-
function prepareBatchDaemonCommandRequest(
15+
async function prepareBatchDaemonCommandRequest(
1616
command: string,
1717
input: CommandInput,
1818
stepNumber: number,
19-
): DaemonCommandRequest {
20-
const writer = (daemonWriters as Readonly<Record<string, DaemonWriter>>)[command];
19+
): Promise<DaemonCommandRequest> {
20+
const writer = (daemonWriters as Readonly<Record<string, AnyDaemonWriter>>)[command];
2121
if (!writer) {
2222
throw new Error(`Missing daemon writer for batch command: ${command}`);
2323
}
@@ -26,7 +26,7 @@ function prepareBatchDaemonCommandRequest(
2626
throw new Error(`Missing command metadata for batch command: ${command}`);
2727
}
2828
try {
29-
return prepareRequestWithMetadataFlags(
29+
return await prepareRequestWithMetadataFlags(
3030
writer,
3131
metadata,
3232
metadata.readInput(input) as CommandInput,
@@ -42,24 +42,24 @@ function prepareBatchDaemonCommandRequest(
4242
}
4343
}
4444

45-
export function prepareDaemonCommandRequest(
45+
export async function prepareDaemonCommandRequest(
4646
command: DaemonCommandName,
4747
input: CommandInput,
48-
): DaemonCommandRequest {
48+
): Promise<DaemonCommandRequest> {
4949
const writer = daemonWriters[command];
5050
if (!writer) {
5151
throw new Error(`Missing daemon writer for command: ${command}`);
5252
}
5353
const metadata = findCommandMetadata(command);
54-
return prepareRequestWithMetadataFlags(writer, metadata, input);
54+
return await prepareRequestWithMetadataFlags(writer, metadata, input);
5555
}
5656

57-
function prepareRequestWithMetadataFlags(
58-
writer: DaemonWriter,
57+
async function prepareRequestWithMetadataFlags(
58+
writer: AnyDaemonWriter,
5959
metadata: ReturnType<typeof findCommandMetadata>,
6060
input: CommandInput,
61-
): DaemonCommandRequest {
62-
const request = writer(input);
61+
): Promise<DaemonCommandRequest> {
62+
const request = await writer(input);
6363
return {
6464
...request,
6565
...(metadata ? { metadataFlags: readMetadataCommandFlags(metadata, request.options) } : {}),

src/commands/family/registry.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { batchCommandFamily } from '../batch/index.ts';
22
import { captureCommandFamily } from '../capture/index.ts';
3-
import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
3+
import type { AnyDaemonWriter, CliReader } from '../cli-grammar/types.ts';
44
import { debuggingCommandFamily } from '../debugging/index.ts';
55
import { interactionCommandFamily } from '../interaction/index.ts';
66
import { managementCommandFamily } from '../management/index.ts';
@@ -18,7 +18,7 @@ import { type CommandFamilyFacet } from './types.ts';
1818
type CommandFamilyRecordMap = {
1919
cliSchemas: CommandSchema;
2020
cliReaders: CliReader;
21-
daemonWriters: DaemonWriter;
21+
daemonWriters: AnyDaemonWriter;
2222
cliOutputFormatters: CliOutputFormatter;
2323
};
2424

@@ -57,7 +57,7 @@ export function listCommandFamilyCliReaders(): Record<CommandFamilyCommandName,
5757
return mergeFamilyRecords('cliReaders') as Record<CommandFamilyCommandName, CliReader>;
5858
}
5959

60-
export function listCommandFamilyDaemonWriters(): Record<string, DaemonWriter> {
60+
export function listCommandFamilyDaemonWriters(): Record<string, AnyDaemonWriter> {
6161
return mergeFamilyRecords('daemonWriters');
6262
}
6363

src/commands/family/types.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AgentDeviceClient } from '../../client/client-types.ts';
22
import type { CommandSchema, CommandSchemaOverride } from '../../cli-schema/types.ts';
3-
import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
3+
import type { AnyDaemonWriter, CliReader } from '../cli-grammar/types.ts';
44
import type {
55
CommandMetadata,
66
ExecutableCommandProjection,
@@ -28,7 +28,7 @@ export type CommandFamilyFacet<TCommandName extends string = string> = {
2828
clientCommandMethods?: Readonly<Record<string, TCommandName>>;
2929
cliSchemas?: Readonly<Partial<Record<TCommandName, CommandSchema>>>;
3030
cliReaders: Readonly<Record<TCommandName, CliReader>>;
31-
daemonWriters?: Readonly<Record<string, DaemonWriter>>;
31+
daemonWriters?: Readonly<Record<string, AnyDaemonWriter>>;
3232
cliOutputFormatters?: Readonly<Partial<Record<TCommandName, CliOutputFormatter>>>;
3333
};
3434

@@ -43,7 +43,7 @@ export type CommandFacetInput<TCommandName extends string = string> = {
4343
cliSchema?: CommandSchemaOverride;
4444
clientMethod?: string;
4545
cliReader: CliReader;
46-
daemonWriter?: DaemonWriter;
46+
daemonWriter?: AnyDaemonWriter;
4747
cliOutputFormatter?: CliOutputFormatter;
4848
text: FacetCommandText;
4949
};
@@ -96,7 +96,7 @@ export function defineCommandFamilyFromFacets<
9696
const cliSchemas: Record<string, CommandSchema> = {};
9797
const clientCommandMethods: Record<string, string> = {};
9898
const cliReaders: Record<string, CliReader> = {};
99-
const daemonWriters: Record<string, DaemonWriter> = {};
99+
const daemonWriters: Record<string, AnyDaemonWriter> = {};
100100
const cliOutputFormatters: Record<string, CliOutputFormatter> = {};
101101

102102
for (const command of family.commands) {

0 commit comments

Comments
 (0)