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
63 changes: 63 additions & 0 deletions src/local/entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
44 changes: 44 additions & 0 deletions src/local/entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,12 +922,56 @@ async function workflowSdkLoaderNodeOption(cwd: string): Promise<string | undefi
const { mkdir, writeFile } = await import('node:fs/promises');
const loaderPath = join(localRunStateRoot(cwd), 'sdk-runtime-loader.mjs');
const runtimeUrl = pathToFileURL(runtime.entryPath).href;

// Workflow files routinely import additional SDK subpaths (e.g.
// `@agent-relay/sdk/github`, `@agent-relay/sdk/relay`) and the sibling
// `@agent-relay/config` package. Resolve all of them against the same
// bundled SDK location so consumer repos don't need a local
// `npm install` of `@agent-relay/*` just to load workflow files under
// Ricky.
//
// runtime.entryPath looks like /<sdkRoot>/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);',
'}',
'',
Expand Down
Loading