diff --git a/src/local/entrypoint.test.ts b/src/local/entrypoint.test.ts index c6e5fcd..8486371 100644 --- a/src/local/entrypoint.test.ts +++ b/src/local/entrypoint.test.ts @@ -3055,6 +3055,69 @@ describe('runLocal', () => { } }); + it('resolves @agent-relay/sdk subpaths and @agent-relay/config via the bundled package', async () => { + // Regression test for the loader bug fixed in PR #92: previously the + // generated sdk-runtime-loader only redirected `@agent-relay/sdk/workflows`, + // so workflow files importing other SDK subpaths (e.g. `/github`) or any + // `@agent-relay/config` subpath failed in consumer repos that hadn't + // `npm install`ed the SDK locally — defeating the point of bundling. + // + // This test imports a non-`workflows` SDK subpath AND an `@agent-relay/config` + // subpath, and asserts the workflow runs successfully without the consumer + // repo having any node_modules. + const { access, mkdir, mkdtemp, rm, writeFile } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const repo = await mkdtemp(join(tmpdir(), 'ricky-sdk-subpaths-repo-')); + const stateHome = await mkdtemp(join(tmpdir(), 'ricky-sdk-subpaths-state-')); + const artifactPath = 'workflows/generated/sdk-subpaths.workflow.ts'; + const previousStateHome = process.env.RICKY_STATE_HOME; + + try { + process.env.RICKY_STATE_HOME = stateHome; + await mkdir(join(repo, 'workflows/generated'), { recursive: true }); + await writeFile( + join(repo, artifactPath), + [ + 'import { workflow } from "@agent-relay/sdk/workflows";', + 'import * as github from "@agent-relay/sdk/github";', + 'import * as relayConfig from "@agent-relay/config/relay-config";', + 'import * as agentConfig from "@agent-relay/config/agent-config";', + 'console.log("workflow=" + typeof workflow);', + 'console.log("sdk-subpath=" + typeof github);', + 'console.log("config-subpath-relay=" + typeof relayConfig);', + 'console.log("config-subpath-agent=" + typeof agentConfig);', + '', + ].join('\n'), + 'utf8', + ); + + const result = await runLocal( + { source: 'workflow-artifact', artifactPath, stageMode: 'run' }, + { + artifactReader: mockArtifactReader('import { workflow } from "@agent-relay/sdk/workflows";'), + localExecutor: { + cwd: repo, + timeoutMs: 10_000, + }, + }, + ); + + expect(result.ok).toBe(true); + expect(result.execution?.status).toBe('success'); + // No consumer-repo node_modules needed. + await expect(access(join(repo, 'node_modules'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + if (previousStateHome === undefined) { + delete process.env.RICKY_STATE_HOME; + } else { + process.env.RICKY_STATE_HOME = previousStateHome; + } + await rm(repo, { recursive: true, force: true }); + await rm(stateHome, { recursive: true, force: true }); + } + }); + it('kills the SDK workflow process tree when the local timeout fires', async () => { const { mkdir, mkdtemp, readFile, rm, writeFile } = await import('node:fs/promises'); const { tmpdir } = await import('node:os'); diff --git a/src/local/entrypoint.ts b/src/local/entrypoint.ts index 1e154b8..ee2c22c 100644 --- a/src/local/entrypoint.ts +++ b/src/local/entrypoint.ts @@ -922,12 +922,56 @@ async function workflowSdkLoaderNodeOption(cwd: string): Promise/dist/workflows/index.js, so + // step up three dirs to get the SDK package root and one more for the + // parent `@agent-relay` scope dir (which also contains `config/`). + const sdkPackageRoot = dirname(dirname(dirname(runtime.entryPath))); + const scopeRoot = dirname(sdkPackageRoot); + const sdkIndexUrl = pathToFileURL(join(sdkPackageRoot, 'dist', 'index.js')).href; + const configPackageRoot = join(scopeRoot, 'config'); + const configIndexUrl = pathToFileURL(join(configPackageRoot, 'dist', 'index.js')).href; + // Anchor URLs for re-resolving subpaths through node's package-exports + // machinery. We pretend the import came from a file inside each package's + // own root, so node walks up to find the @agent-relay/sdk (or config) + // entry in Ricky's bundled node_modules and consults that package's + // exports map. This is what `nextResolve(specifier, { parentURL })` + // is for — passing a fully-qualified file:// URL would skip exports + // resolution and try to load the literal path. + const sdkParentUrl = pathToFileURL(join(sdkPackageRoot, 'index.js')).href; + const configParentUrl = pathToFileURL(join(configPackageRoot, 'index.js')).href; + const loaderSource = [ `const sdkWorkflowsUrl = ${JSON.stringify(runtimeUrl)};`, + `const sdkIndexUrl = ${JSON.stringify(sdkIndexUrl)};`, + `const configIndexUrl = ${JSON.stringify(configIndexUrl)};`, + `const sdkParentUrl = ${JSON.stringify(sdkParentUrl)};`, + `const configParentUrl = ${JSON.stringify(configParentUrl)};`, + 'const SDK_SUBPATH_PREFIX = "@agent-relay/sdk/";', + 'const CONFIG_SUBPATH_PREFIX = "@agent-relay/config/";', 'export async function resolve(specifier, context, nextResolve) {', " if (specifier === '@agent-relay/sdk/workflows') {", ' return { url: sdkWorkflowsUrl, shortCircuit: true };', ' }', + " if (specifier === '@agent-relay/sdk') {", + ' return { url: sdkIndexUrl, shortCircuit: true };', + ' }', + " if (specifier === '@agent-relay/config') {", + ' return { url: configIndexUrl, shortCircuit: true };', + ' }', + ' if (specifier.startsWith(SDK_SUBPATH_PREFIX)) {', + ' return nextResolve(specifier, { ...context, parentURL: sdkParentUrl });', + ' }', + ' if (specifier.startsWith(CONFIG_SUBPATH_PREFIX)) {', + ' return nextResolve(specifier, { ...context, parentURL: configParentUrl });', + ' }', ' return nextResolve(specifier, context);', '}', '',