Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/build-path-conformance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"agent-bundle": minor
---

Move the generated-executable build path onto fully documented bundler
surfaces (Rspack/Rslib/Rsbuild conformance audit).

- Generated wrapper entries and registry modules (the stdio MCP entry shell,
`main` process envelopes, `agent-bundle/mcp-apps` registries) are now
materialized as real files under the reserved `.agent-bundle-virtual/`
directory for the duration of one Rslib build — replacing the experimental
`rspack.experiments.VirtualModulesPlugin` and its undocumented
real-file-overlay of the framework's own module as the entry anchor. The
files never reach a published artifact and never count as authored source
provenance; emitted bundles keep their behavior byte for byte (the only
content shift is one scope-hoisting identifier now derived from the stable
generated-entry name instead of the framework's install-dependent bundle
filename).
- The self-contained-artifact invariant now closes the `output.externals`
hole: a `tools` hatch that externalizes a reserved specifier
(`agent-bundle/mcp-entry`, `agent-bundle/mcp-apps`, or any generated
registry name) fails the build with a hard diagnostic — statically for
string/RegExp/object externals, and via a post-build residual-import scan
of every emitted bundle for function-form externals.
- Dist cleaning is now a framework invariant rather than a profile default:
because generated sources are materialized under the output root, a
`tools.rsbuild.output.cleanDistPath: true` hatch would delete this build's
own entry modules and any sibling entry already emitted into the shared
staged root. It is pinned off after the hatch merge and asserted on the
resolved environment config.
- Pre-build inspection assertions are keyed by the documented Rslib `lib.id`
(`origin.environmentConfigs[id]` and the Rspack config `name`) instead of
relying on undocumented array ordering, and reserved aliases use Rspack's
exact-match (`$`) key form.
- Per-entry Rslib configs compose through Rslib's own documented
`mergeRslibConfig` (merged by `id`) with the framework invariant hooks
typed against each executing engine's own `Rspack.Configuration` and
returning the config, removing every `as never` cast; the one remaining
type seam between the public hatch types and Rslib's nested engine is a
single documented conversion. The dual-engine reality of the hatch —
Rslib's nested Rsbuild/Rspack (2.1.x line) on the executable path, the
workspace `@rsbuild/core` (2.2.x) on the MCP Apps path — is now documented
on `AgentBundleToolsConfig` and in the entry-conventions reference,
steering hatch authors to the `{ rspack }` utils argument instead of
importing `@rspack/core`.
- The unused direct `@rspack/core` dependency is dropped per Rslib guidance
(its types resolve through `@rsbuild/core`), the `lib` build's declaration
output is described accurately as a bundleless `.d.ts` graph, and the
`mcp run` docs no longer claim programmatic builds load the same `.env`
set (they load none).
37 changes: 29 additions & 8 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,26 @@ The hatch is bounded: the framework invariant hook runs after the consumer's
`tools.rspack`, and the resolved-config assertions still run after the merge.
A hatch value that breaks an artifact contract (async chunks, output roots,
self-containment) fails the build with a hard diagnostic instead of silently
overriding the contract. The hatch customizes *how code compiles*, never
*what the artifact promises*.
overriding the contract. Reserved module specifiers are protected the same
way: a hatch that externalizes `agent-bundle/mcp-entry` or a generated
registry specifier (such as `agent-bundle/mcp-apps`) fails the build with a
hard diagnostic — at config inspection for statically visible `externals`,
and through a post-build scan of the emitted bundle for function-form
`externals` — because generated executables must stay self-contained. The
hatch customizes *how code compiles*, never *what the artifact promises*.

The hatch executes under two different bundler engine copies. Artifact
scripts, MCP entries, hook wrappers, and the package build compile through
Rslib, which runs the Rsbuild/Rspack versions nested inside `@rslib/core`
(currently the 2.1.x line); MCP App views compile through the
workspace-pinned `@rsbuild/core` (currently 2.2.x), until Rslib catches up to
the Rsbuild 2.2 line. So a hatch must never construct plugins or run
`instanceof` checks against an imported `@rspack/core`: a class imported from
a separately installed `@rspack/core` has a different identity than whichever
engine executes the config. Use instead the utils argument Rslib/Rsbuild pass
to `tools.rspack` mutator functions —
`tools: { rspack: (config, { rspack }) => { ... } }` — which always hands the
engine's own `rspack` object.

### `agent-bundle inspect --bundler`

Expand Down Expand Up @@ -211,12 +229,15 @@ temporary artifact is built first.

The runner loads the project-root `.env` set by default — rsbuild's `loadEnv`
conventions (`.env`, `.env.local`, `.env.<mode>`, `.env.<mode>.local`, with
`--mode` selecting the variants), the same files `createRslib` reads for the
same consumers at build time — so operator credentials configured for the
plugin reach a bare `mcp run` without a wrapper script. `--env-file <path>`
(repeatable, Node's `--env-file` dialect, later files win) replaces the
conventional set with exactly the named files, and `--no-env` skips the layer
entirely; a named file that cannot be read is an error, never a silent skip.
`--mode` selecting the variants) — so operator credentials configured for the
plugin reach a bare `mcp run` without a wrapper script. This is a
launch-time-only layer: the framework's programmatic Rslib builds never pass
`loadEnv` to `createRslib`, so `agent-bundle build` and the package build read
no `.env` file at all, and nothing from `.env` can leak into a compiled
artifact. `--env-file <path>` (repeatable, Node's `--env-file` dialect, later
files win) replaces the conventional set with exactly the named files, and
`--no-env` skips the layer entirely; a named file that cannot be read is an
error, never a silent skip.

The child environment is composed from three layers. This table is the
canonical precedence order (highest wins):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ const buildInvocationEntry = async (compilerRoot: string, cwd = process.cwd()):
config: createRscRuntimeRsbuildConfig({ compilerRoot, mode: 'development' }),
cwd,
});
await rsbuild.build();
const buildResult = await rsbuild.build();
await buildResult.close();
return join(compilerRoot, 'rsc', 'dev', 'invoke.js');
};

Expand Down Expand Up @@ -1214,7 +1215,7 @@ setInterval(() => undefined, 1_000);
const workerSource = join(copied.projectRoot, 'src', 'rsc', 'worker.tsx');
const source = await readFile(workerSource, 'utf8');
await writeFile(workerSource, source.replace('RSC worker received an invalid event', 'RSC worker received an invalid event generation-two'));
await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two');
await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two', 15_000);
const g2 = session.status().activeVector!.runtimeGenerationId;
await writeFile(g1Worker, originalG1Worker);

Expand Down Expand Up @@ -1265,7 +1266,7 @@ test('replays an exact historical surface after generation two removes it', asyn
const definition = join(copied.projectRoot, 'src', 'definition.ts');
const source = await readFile(definition, 'utf8');
await writeFile(definition, source.replace(" host: 'claude',", " host: 'codex',"));
await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two');
await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two', 15_000);
const g2 = session.status().activeVector!.runtimeGenerationId;

await expect(session.replay({ expectedGenerationId: g1, mode: 'exact', runId: run.id }))
Expand Down Expand Up @@ -1302,7 +1303,7 @@ test('releases an exact historical lease when four active workers reject its adm

const definition = join(copied.projectRoot, 'src', 'definition.ts');
await appendFile(definition, '\n// exact-lease-capacity-g2\n');
await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two');
await waitFor(() => session.status().activeVector?.runtimeGenerationId !== g1, 'Timed out waiting for generation two', 15_000);
const g2 = session.status().activeVector!.runtimeGenerationId;
const marker = join(storageRoot, 'blocked-exact-lease-workers.txt');
const worker = join(storageRoot, 'generation-store', 'generations', g2, 'rsc', 'rsc', 'index.js');
Expand Down
1 change: 0 additions & 1 deletion packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@
"@rsbuild/plugin-react": "2.1.0",
"@rslib/core": "0.23.2",
"@rslint/core": "0.8.2",
"@rspack/core": "2.2.1",
"@rstackjs/load-config": "0.1.2",
"acorn": "8.18.0",
"ajv": "8.20.0",
Expand Down
27 changes: 16 additions & 11 deletions packages/agent-bundle/src/build/mcp-apps.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createRsbuild, mergeRsbuildConfig, type RsbuildConfig } from '@rsbuild/core';
import { createRsbuild, mergeRsbuildConfig, type RsbuildConfig, type Rspack } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
import { readFile } from 'node:fs/promises';
import { extname, resolve } from 'node:path';
Expand Down Expand Up @@ -42,6 +42,8 @@ const assertResolvedViewConfig = (
}
for (const environment of Object.values(environments)) {
if (
// Cleaning the shared staged target root would delete sibling outputs.
environment.output.cleanDistPath !== false ||
environment.output.filenameHash !== false ||
environment.output.inlineScripts !== true ||
environment.output.inlineStyles !== true ||
Expand Down Expand Up @@ -138,7 +140,7 @@ export const composeMcpAppsRsbuildConfig = (
sources: readonly Pick<NormalizedMcpApp, 'name' | 'source' | 'template'>[],
options: { readonly outDir: string; readonly tools?: AgentBundleToolsConfig },
): RsbuildConfig => {
const profile = {
const profile: RsbuildConfig = {
environments: Object.fromEntries(sources.map((source) => [source.name, {
...(usesReactSyntax(source.source) ? { plugins: [pluginReact()] } : {}),
html: {
Expand All @@ -150,7 +152,6 @@ export const composeMcpAppsRsbuildConfig = (
logLevel: 'silent' as const,
mode: 'production' as const,
output: {
cleanDistPath: false,
dataUriLimit: Number.MAX_SAFE_INTEGER,
distPath: { html: 'mcp-apps', root: options.outDir },
filename: { css: '[name].css', html: '[name].html', js: '[name].js' },
Expand All @@ -164,15 +165,19 @@ export const composeMcpAppsRsbuildConfig = (
server: { publicDir: false },
splitChunks: false,
};
const enforceInvariants = (config: { output: { asyncChunks?: boolean } }): void => {
config.output.asyncChunks = false;
const enforceInvariants = (config: Rspack.Configuration): Rspack.Configuration => {
config.output = { ...config.output, asyncChunks: false };
return config;
};
return mergeRsbuildConfig(
profile as never,
...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild as never]),
...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } } as never]),
{ tools: { rspack: enforceInvariants } } as never,
) as RsbuildConfig;
return mergeRsbuildConfig<RsbuildConfig>(
profile,
...(options.tools?.rsbuild === undefined ? [] : [options.tools.rsbuild]),
...(options.tools?.rspack === undefined ? [] : [{ tools: { rspack: options.tools.rspack } }]),
// Merged last so the hatch cannot reach either invariant: dist cleaning
// would delete sibling outputs already emitted into the shared staged
// target root, so it stays off no matter what the consumer asks for.
{ output: { cleanDistPath: false }, tools: { rspack: enforceInvariants } },
);
};

export const compileMcpApps = async (
Expand Down
9 changes: 5 additions & 4 deletions packages/agent-bundle/src/build/package-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { buildWithRslib, type RslibEntry } from './rslib.ts';
/**
* The framework-owned npm package build: `bin` entries become self-executing
* `dist/bin/<name>.js` bundles (shebang + executable bit) and the `lib` entry
* becomes `dist/<name>.js` (+ bundled `.d.ts`), all through the same Rslib
* synthesis, invariant assertions, and staged atomic publication as artifact
* executables. This is the build audiobook-curator previously needed a second
* bundler config, a tsconfig, and a hand-written bin shim to produce.
* becomes `dist/<name>.js` (+ a bundleless `.d.ts` declaration graph), all
* through the same Rslib synthesis, invariant assertions, and staged atomic
* publication as artifact executables. This is the build audiobook-curator
* previously needed a second bundler config, a tsconfig, and a hand-written
* bin shim to produce.
*/

const binShebang = '#!/usr/bin/env node';
Expand Down
Loading
Loading