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
5 changes: 5 additions & 0 deletions .changeset/runtime-rebundle-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Keep generated stdio entries startable when private runtime modules are added (#636).
104 changes: 57 additions & 47 deletions packages/agent-bundle/rslib.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const appDeclarationEntrypointPlugin: RsbuildPlugin = {
const buildCacheDirectory = process.env['AGENT_BUNDLE_RSLIB_CACHE_DIRECTORY'];

/**
* The `id` of the single lib entry. To Rslib an id is a name: it labels the
* The `id` of the public lib entry. To Rslib an id is a name: it labels the
* Rsbuild environment the entry becomes (`esm` when unset —
* `composeRsbuildEnvironments` in @rslib/core), so it shows in build logs,
* selects the entry for `rslib build --lib`, and keys the persistent build
Expand All @@ -69,6 +69,38 @@ const buildCacheDirectory = process.env['AGENT_BUNDLE_RSLIB_CACHE_DIRECTORY'];
* entry's fields into the pools' test build.
*/
export const agentBundleLibId = 'esm-node';
export const agentBundleRuntimeLibId = 'runtime-node';

const publicEntries = {
api: './src/api.ts',
cli: './src/cli.ts',
config: './src/config/index.ts',
eval: './src/eval/index.ts',
index: './src/index.ts',
'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts',
'mcp-apps': './src/mcp-apps.ts',
'route-invocation-child': './src/dev/routes/route-invocation-child.ts',
rstest: './src/rstest/index.ts',
test: './src/test/index.ts',
'test/browser': './src/test/browser.ts',
};

const runtimeEntries = {
app: './src/app/index.ts',
'cli-entry': './src/cli-entry.ts',
'event-ipc': './src/events/ipc.ts',
'event-project': './src/events/project.ts',
'install-entry': './src/install-entry.ts',
'launch-env': './src/launch-env.ts',
'mcp-entry': './src/mcp-entry.ts',
'mcp-server-runtime': process.env['AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE'] === '1'
? './tests/fixtures/runtime-rebundle/mcp-server-runtime.ts'
: './src/mcp-server-runtime.ts',
meta: './src/meta.ts',
routes: './src/routes/public.ts',
'terminal-capability': './src/terminal-capability.ts',
'web-host': './src/web-host.ts',
};

export default defineConfig({
lib: [
Expand All @@ -85,15 +117,35 @@ export default defineConfig({
// no packed declaration, reachable or not, may import a devDependency.
dts: true,
format: 'esm',
output: {
copy: [
{ from: resolve(import.meta.dirname, '../workbench/dist'), to: 'workbench', info: { minimized: true } },
{ from: resolve(import.meta.dirname, 'web-host-dist'), to: 'web-host', info: { minimized: true } },
],
},
plugins: [appDeclarationEntrypointPlugin],
source: {
entry: publicEntries,
},
syntax: 'es2022',
},
{
id: agentBundleRuntimeLibId,
bundle: true,
dts: false,
format: 'esm',
source: {
entry: runtimeEntries,
},
// These entries are inputs to a second Rspack compilation when the
// compiler generates an artifact. Keeping them outside the public
// graph's dynamic runtime import gives their transitive private modules
// a re-bundle-safe placement without promoting siblings to entries.
syntax: 'es2022',
},
],
output: {
cleanDistPath: true,
copy: [
{ from: resolve(import.meta.dirname, '../workbench/dist'), to: 'workbench', info: { minimized: true } },
{ from: resolve(import.meta.dirname, 'web-host-dist'), to: 'web-host', info: { minimized: true } },
],
filenameHash: false,
legalComments: 'linked',
target: 'node',
Expand All @@ -104,7 +156,6 @@ export default defineConfig({
plugins: [
// Suggestions stay informational; errors and warnings block publishing.
pluginPublint({ throwOn: 'warning' }),
appDeclarationEntrypointPlugin,
// The bundled TypeScript 5 parser's eager `getNodeSystem()` reads the
// CommonJS `__filename`/`__dirname` globals, which the ESM output does
// not define and which Rspack's `node-module` rewrite (disabled below)
Expand Down Expand Up @@ -141,46 +192,5 @@ export default defineConfig({
define: {
__AGENT_BUNDLE_VERSION__: JSON.stringify(packageManifest.version),
},
entry: {
api: './src/api.ts',
app: './src/app/index.ts',
cli: './src/cli.ts',
'cli-entry': './src/cli-entry.ts',
config: './src/config/index.ts',
eval: './src/eval/index.ts',
'event-ipc': './src/events/ipc.ts',
'event-project': './src/events/project.ts',
index: './src/index.ts',
'install-entry': './src/install-entry.ts',
'launch-env': './src/launch-env.ts',
'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts',
'mcp-apps': './src/mcp-apps.ts',
'mcp-entry': './src/mcp-entry.ts',
meta: './src/meta.ts',
// Same reason as `mcp-tasks` below: the runtime's only other private
// sibling. Concatenated into the runtime's chunk it makes that chunk
// host two modules, so rslib synthesizes the runtime's namespace
// object (for `agent-bundle/test`'s dynamic import) through its own
// `__webpack_require__`, and the generated stdio entry fails to start
// with `__webpack_modules__[moduleId] is not a function`.
'mcp-schema-projection': './src/mcp-schema-projection.ts',
'mcp-server-runtime': './src/mcp-server-runtime.ts',
// Its own entry so it is emitted as a chunk beside the runtime rather
// than concatenated into it: a generated artifact bundles
// `dist/mcp-server-runtime.js`, and a chunk that also hosts a sibling
// module carries rslib's `__webpack_require__` runtime import, whose
// identifiers shadow the artifact bundler's own runtime.
'mcp-tasks': './src/mcp-tasks.ts',
// The route authoring surface: types plus the compile-time helpers a
// route module may import at run time without pulling the compiler
// into its generated bundle.
routes: './src/routes/public.ts',
'route-invocation-child': './src/dev/routes/route-invocation-child.ts',
rstest: './src/rstest/index.ts',
'terminal-capability': './src/terminal-capability.ts',
test: './src/test/index.ts',
'test/browser': './src/test/browser.ts',
'web-host': './src/web-host.ts',
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { assertPrivateSiblingLoaded } from './private-sibling.ts';

assertPrivateSiblingLoaded();

export * from '../../../src/mcp-server-runtime.ts';
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const marker = 'AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE_EXECUTED';

process.env[marker] = '1';

export const assertPrivateSiblingLoaded = (): void => {
if (process.env[marker] !== '1') {
throw new Error('Synthetic runtime sibling did not execute.');
}
};
13 changes: 11 additions & 2 deletions packages/agent-bundle/tests/packed-stdio-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,18 @@ interface McpJson {
* (tests/projection/) covers the same route protocol surface at a fraction of
* the cost and explicitly does not claim any of this.
*/
it('serves compiled routes and durable state across packed process restarts', async () => {
it.each([
['release', 'agent-bundle'],
['private runtime sibling', 'agent-bundle-runtime-rebundle'],
] as const)('serves compiled routes from the %s package across packed process restarts', async (_variant, packageName) => {
const [agentBundle, runtime, markdownStream] = await Promise.all([
sharedPackedTarball('agent-bundle'),
sharedPackedTarball(packageName),
sharedPackedTarball('runtime'),
sharedPackedTarball('markdown-stream'),
]);
expect(agentBundle.variant).toBe(
packageName === 'agent-bundle-runtime-rebundle' ? 'runtime-rebundle' : undefined,
);
const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-stdio-'));
const project = join(consumer, 'project');
const artifact = join(project, 'artifact');
Expand Down Expand Up @@ -253,6 +259,9 @@ it('serves compiled routes and durable state across packed process restarts', as
// fills `HARNESS_FROM_FILE` and `.env.local`'s `HARNESS_LOCAL`, the host's
// exported `HARNESS_HOST_WINS` is untouched, and nothing was logged.
for (const [name, value] of [
...(packageName === 'agent-bundle-runtime-rebundle'
? [['AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE_EXECUTED', '1'] as const]
: []),
['HARNESS_FROM_FILE', 's3cr3t-from-file'],
['HARNESS_LOCAL', 'from-local'],
['HARNESS_HOST_WINS', 'from-host'],
Expand Down
18 changes: 15 additions & 3 deletions packages/agent-bundle/tests/rstest-rslib-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { RslibConfig } from '@rslib/core';
import { withRslibConfig } from '@rstest/adapter-rslib';
import { describe, expect, it, type ExtendConfig } from '@rstest/core';

import agentBundleRslibConfig from '../rslib.config.ts';
import agentBundleRslibConfig, { agentBundleRuntimeLibId } from '../rslib.config.ts';
import packageManifest from '../package.json' with { type: 'json' };
import { agentBundleRslibAdapterOptions, rstestHygiene, withAgentBundleRslibConfig } from '../../../rstest.rslib.ts';
import { agentBundlePackageRoot } from './helpers/workspace-paths.ts';
Expand All @@ -30,9 +30,21 @@ let poolConfig: Promise<ExtendConfig> | undefined;
const resolvedPoolConfig = (): Promise<ExtendConfig> => (poolConfig ??= Promise.resolve(withAgentBundleRslibConfig()({})));

describe('rstest.rslib.ts', () => {
it('passes as libId the id of the single lib entry', () => {
it('selects the public lib while the re-bundled runtime has its own profile', () => {
expect(agentBundleRslibAdapterOptions.cwd).toBe(agentBundlePackageRoot);
expect((agentBundleRslibConfig.lib ?? []).map((lib) => lib.id)).toEqual([agentBundleRslibAdapterOptions.libId]);
expect((agentBundleRslibConfig.lib ?? []).map((lib) => lib.id)).toEqual([
agentBundleRslibAdapterOptions.libId,
agentBundleRuntimeLibId,
]);
expect(agentBundleRslibConfig.lib?.find((lib) => lib.id === agentBundleRuntimeLibId)).toMatchObject({
dts: false,
source: {
entry: {
app: './src/app/index.ts',
'mcp-server-runtime': './src/mcp-server-runtime.ts',
},
},
});
});

it('reads the lib entry through libId only — the adapter falls back to an empty entry without a diagnostic', async () => {
Expand Down
12 changes: 11 additions & 1 deletion packages/agent-bundle/tests/support/shared-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,20 @@ export interface SharedPack {
/** The package's own `npm pack --json` entry recorded when the tarball was produced. */
readonly packOutput: SharedPackOutput;
readonly tarball: string;
readonly variant?: 'runtime-rebundle';
}

export type SharedPackPackage = 'agent-bundle' | 'create-agent-bundle' | 'markdown-stream' | 'runtime';
export type SharedPackPackage =
| 'agent-bundle'
| 'agent-bundle-runtime-rebundle'
| 'create-agent-bundle'
| 'markdown-stream'
| 'runtime';

/** packages/ directory and npm package name for each shared-pack key. */
const sharedPackPackages: Readonly<Record<SharedPackPackage, Readonly<{ directory: string; npmName: string }>>> = {
'agent-bundle': { directory: 'agent-bundle', npmName: 'agent-bundle' },
'agent-bundle-runtime-rebundle': { directory: 'agent-bundle', npmName: 'agent-bundle' },
'create-agent-bundle': { directory: 'create-agent-bundle', npmName: 'create-agent-bundle' },
// `@agent-bundle/runtime` depends on it by exact version; a consumer that
// installs the runtime tarball needs this one alongside until that version
Expand Down Expand Up @@ -100,6 +107,9 @@ const packOnce = async (packageName: SharedPackPackage): Promise<SharedPack> =>
if (sharedDirectory !== undefined && sharedDirectory.length > 0) {
return JSON.parse(await readFile(join(sharedDirectory, `${packageName}.json`), 'utf8')) as SharedPack;
}
if (packageName === 'agent-bundle-runtime-rebundle') {
throw new Error('The runtime re-bundle fixture is prepared by `pnpm test:packed`; run the packed pool through that script.');
}
// Ad-hoc single-file runs have no run-level tarball, so build once (unless
// the caller marked the workspace dist prebuilt) and pack into a
// per-process temporary directory that is dropped on exit. The build
Expand Down
7 changes: 3 additions & 4 deletions rstest.rslib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,9 @@ export const rstestHygiene = {
* The lib entry is found by `libId` (line 53: `lib.find((l) => l.id ===
* libId) || {}`); without a `libId`, or with one no entry carries, the entry
* is silently `{}` and only the top-level fields count. That is why the entry
* has an `id` and these options pass it: the top-level fields happen to carry
* everything the pools need, so the result was right by accident, and a field
* moved into the entry — `output.target`, `source.define` — would have
* vanished from every pool without a diagnostic. Of the entry, only `source`,
* has an `id` and these options pass it: a field moved into the selected
* public entry — `output.target`, `source.define` — would otherwise vanish
* from every pool without a diagnostic. Of the entry, only `source`,
* `output`, `tools`, `plugins`, and `resolve` are merged over the top-level
* config (lines 54-61); `format` is read once more, directly, as the fallback
* for `output.module` (line 105).
Expand Down
50 changes: 49 additions & 1 deletion scripts/run-packed-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* to rstest.
*/
import { execFile as executeFile, spawn } from 'node:child_process';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -59,6 +59,54 @@ try {
`${JSON.stringify({ packOutput, tarball: join(packDirectory, packOutput.filename) })}\n`,
);
}));
// Build the synthetic private sibling into a separate package image. The
// normal dist and shared release tarball above remain the publish candidate.
const fixtureDist = join(packDirectory, 'runtime-rebundle-dist');
await execFile(join(repositoryRoot, 'node_modules', '.bin', 'rslib'), [
'build',
'--config',
join(repositoryRoot, 'packages', 'agent-bundle', 'rslib.config.ts'),
'--dist-path',
fixtureDist,
], {
cwd: repositoryRoot,
env: {
...environment,
AGENT_BUNDLE_RSLIB_CACHE_DIRECTORY: join(packDirectory, 'runtime-rebundle-cache'),
AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE: '1',
NODE_ENV: 'production',
},
});
const fixturePackage = join(packDirectory, 'runtime-rebundle-package');
await mkdir(fixturePackage);
const agentBundlePackageRoot = join(repositoryRoot, 'packages', 'agent-bundle');
const agentBundleManifest = JSON.parse(await readFile(join(agentBundlePackageRoot, 'package.json'), 'utf8'));
await Promise.all(['package.json', ...agentBundleManifest.files.filter((name) => name !== 'dist')].map((name) => cp(
join(agentBundlePackageRoot, name),
join(fixturePackage, name),
{ recursive: true },
)));
await cp(fixtureDist, join(fixturePackage, 'dist'), { recursive: true });
const fixturePackDirectory = join(packDirectory, 'runtime-rebundle-pack');
await mkdir(fixturePackDirectory);
const { stdout: fixturePacked } = await execFile('npm', [
'pack',
'--json',
'--pack-destination',
fixturePackDirectory,
], {
cwd: fixturePackage,
env: { ...environment, NODE_ENV: 'production' },
});
const fixturePackOutput = packOutputFromJson(fixturePacked, 'agent-bundle');
await writeFile(
join(packDirectory, 'agent-bundle-runtime-rebundle.json'),
`${JSON.stringify({
packOutput: fixturePackOutput,
tarball: join(fixturePackDirectory, fixturePackOutput.filename),
variant: 'runtime-rebundle',
})}\n`,
);
process.exitCode = await run('pnpm', ['exec', 'rstest', '--config', 'rstest.packed.config.ts', ...rstestArguments], {
AGENT_BUNDLE_PACKAGE_PREBUILT: '1',
...(releasePool ? { AGENT_BUNDLE_PACKED_RELEASE: '1' } : {}),
Expand Down
Loading