fix(ai-builder): Run each eval build in its own empty project (no-changelog) (#40715)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
José Braulio González Valido 2026-10-08 21:15:11 +00:00 committed by GitHub
parent cef87cfcab
commit 5b78e8b655
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 525 additions and 41 deletions

View File

@ -130,8 +130,10 @@ the cursor instead.
n8n — it targets a long-lived container per slot, `restart: unless-stopped`,
booted once for the whole sweep and never reset between cases. The nightly
runs ~12 of them (runners x slots), so one n8n process serves dozens of cases
back to back. Isolation is per-case *user* (`run/lane-users.ts`
`provisionCaseBuildUser`), not per-case instance. Assume anything the backend
back to back. Isolation is per build, not per instance (`run/lane-users.ts`):
an MCP build acts as a fresh member (`provisionCaseBuildUser`), an
orchestrator build runs in a fresh member's personal project
(`provisionBuildProject`). Assume anything the backend
holds in memory outlives the case that created it, and size it for a
multi-hour process rather than one run.
- **`eval-pr-comment.md`** is posted verbatim by CI. The comment uses an

View File

@ -856,6 +856,10 @@ A direction governs only what it covers; otherwise the proxy answers every quest
- Edge cases — empty data, missing fields, single vs multiple items
- Error scenarios only if the workflow is expected to handle them gracefully. Most agent-built workflows don't include error handling, so "the workflow crashes on invalid input" is a legitimate finding, not a test-case failure.
### Build isolation
Each orchestrator build runs in its own empty project: the personal project of a freshly invited member, named like the lane owner. The owner still drives the build; the member only owns the project. An unlicensed instance cannot create team projects, so a new user is the only way to get a new project. Cleanup deletes the member, which also deletes its project and anything a timed-out build left there; under `--keep-workflows` the members stay, with their projects. Like the MCP lane, this needs a lane without SMTP, so invites return their accept token.
### Credentials
By default a build sees **no credentials**: the harness pins every build thread's credential view to the case's declared set (empty unless declared), so concurrent cases — and whatever happens to live on the instance — can never leak into a build. Every node mocks during verification.

View File

@ -119,4 +119,30 @@ describe('executeAgentScenario', () => {
expect(reseedScenarioTables).not.toHaveBeenCalled();
expect(calls).toEqual(['execute']);
});
it('runs the agent in the project it was built in', async () => {
const client = clientRecording([]);
await executeAgentScenario(
client,
'agent-1',
scenario,
'AGENT CONTEXT',
silentLogger,
10_000,
undefined,
undefined,
undefined,
undefined,
'build-project',
);
expect(client.executeAgentWithLlmMock).toHaveBeenCalledWith(
'agent-1',
'build-project',
scenario.dataSetup,
10_000,
);
expect(client.getPersonalProjectId).not.toHaveBeenCalled();
});
});

View File

@ -103,6 +103,23 @@ describe('agentHandler', () => {
expect(rendered).not.toContain('abcdef1234567890');
});
it('reads the agent from the project the ref names', async () => {
const getPersonalProjectId: Mock = vi.fn();
const getAgentConfig: Mock = vi.fn().mockResolvedValue(validConfig);
const getAgentSkills: Mock = vi.fn().mockResolvedValue({});
const client = {
getPersonalProjectId,
getAgentConfig,
getAgentSkills,
} as unknown as N8nClient;
await agentHandler.fetch({ type: 'agent', id: 'agent-1', projectId: 'build-project' }, client);
expect(getAgentConfig).toHaveBeenCalledWith('build-project', 'agent-1');
expect(getAgentSkills).toHaveBeenCalledWith('build-project', 'agent-1');
expect(getPersonalProjectId).not.toHaveBeenCalled();
});
it('keeps node-tool credential configs available to the judge and redacts them for export', async () => {
const config = {
...validConfig,

View File

@ -132,6 +132,31 @@ describe('configEvalHandler', () => {
expect(getDataTableRows).not.toHaveBeenCalled();
});
it('fetch() reads the dataset table from the project the ref names', async () => {
const workflowId = 'wf-3';
const dataTableId = 'dt-3';
const getPersonalProjectId: Mock = vi.fn();
const getDataTableColumns: Mock = vi.fn().mockResolvedValue([]);
const getDataTableRows: Mock = vi.fn().mockResolvedValue({ count: 0, data: [] });
const client = {
getWorkflowEvaluationConfigs: vi
.fn()
.mockResolvedValue([dataTableConfig(workflowId, dataTableId)]),
getPersonalProjectId,
getDataTableColumns,
getDataTableRows,
} as unknown as N8nClient;
await configEvalHandler.fetch(
{ type: 'config-eval', id: workflowId, projectId: 'build-project' },
client,
);
expect(getDataTableColumns).toHaveBeenCalledWith('build-project', dataTableId);
expect(getDataTableRows).toHaveBeenCalledWith('build-project', dataTableId);
expect(getPersonalProjectId).not.toHaveBeenCalled();
});
it('renderArtifact() surfaces config + dataset detail and never mentions the owning workflow', () => {
const workflowId = 'wf-should-not-appear';
const configs = [dataTableConfig(workflowId, 'dt-1')];

View File

@ -0,0 +1,129 @@
import { vi } from 'vitest';
import type { N8nClient } from '../clients/n8n-client';
import { buildWorkflow } from '../harness/build-workflow';
import type { EvalLogger } from '../harness/logger';
import { extractOutcomeFromEvents } from '../outcome/event-parser';
// Only thread setup and result bookkeeping matter here; the loop and the proxy
// are stubbed as in build-workflow-observer-threshold.
vi.mock('../harness/chat-loop', () => ({
SSE_SETTLE_DELAY_MS: 0,
startSseConnection: vi.fn().mockResolvedValue(undefined),
waitForAllActivity: vi.fn().mockResolvedValue(undefined),
runMultiTurnConversation: vi.fn().mockResolvedValue(undefined),
recordUserTurn: vi.fn(),
}));
vi.mock('../utils/user-proxy', () => ({
UserProxyLlm: class {
respondToConfirmation = vi.fn().mockResolvedValue({ approve: true });
ingestEvents = vi.fn();
decideFollowUp = vi.fn().mockResolvedValue({ kind: 'done' });
getDecisionStats = vi.fn().mockReturnValue({});
},
}));
vi.mock('../outcome/workflow-discovery', () => ({
buildAgentOutcome: vi.fn().mockResolvedValue({
workflowsCreated: [],
executionsRun: [],
dataTablesCreated: [],
finalText: 'done',
workflowJsons: [],
}),
extractWorkflowIdsFromMessages: vi.fn().mockReturnValue([]),
}));
vi.mock('../outcome/event-parser', async (importOriginal) => {
const actual = await importOriginal<typeof import('../outcome/event-parser')>();
return { ...actual, extractOutcomeFromEvents: vi.fn(actual.extractOutcomeFromEvents) };
});
const silentLogger: EvalLogger = {
info: () => {},
verbose: () => {},
success: () => {},
warn: () => {},
error: () => {},
isVerbose: false,
};
function makeClient(sendMessage = vi.fn().mockResolvedValue({ runId: 'run-1' })) {
const mocks = {
getPersonalProjectId: vi.fn().mockResolvedValue('owner-project'),
ensureThread: vi.fn().mockResolvedValue(undefined),
createCredential: vi.fn().mockResolvedValue({ id: 'cred-1' }),
setThreadCredentialAllowlist: vi.fn().mockResolvedValue(undefined),
sendMessage,
getThreadMessages: vi.fn().mockResolvedValue({ messages: [] }),
listWorkflows: vi.fn().mockResolvedValue([]),
};
return { client: mocks as unknown as N8nClient, mocks };
}
const baseConfig = {
skipWorkflowChecks: true,
preRunWorkflowIds: new Set<string>(),
claimedWorkflowIds: new Set<string>(),
logger: silentLogger,
conversation: [{ role: 'user' as const, text: 'build a thing' }],
buildProject: vi.fn().mockResolvedValue({ userId: 'build-user', projectId: 'build-project' }),
};
describe('buildWorkflow build project', () => {
it("binds the thread to the build's own project and creates the case's credentials there", async () => {
const { client, mocks } = makeClient();
const result = await buildWorkflow({
client,
...baseConfig,
credentials: [{ type: 'slackApi' }],
});
expect(mocks.ensureThread.mock.calls[0]?.[1]).toBe('build-project');
expect(mocks.createCredential.mock.calls[0]?.[4]).toBe('build-project');
expect(mocks.getPersonalProjectId).not.toHaveBeenCalled();
expect(result).toMatchObject({ buildProjectId: 'build-project', buildUserId: 'build-user' });
});
it('keeps the build user on a failed build, so cleanup still removes it', async () => {
const { client } = makeClient(vi.fn().mockRejectedValue(new Error('chat send failed')));
const result = await buildWorkflow({ client, ...baseConfig });
expect(result.success).toBe(false);
expect(result).toMatchObject({ buildProjectId: 'build-project', buildUserId: 'build-user' });
});
it('fails as a harness problem, before any thread exists, when no project can be provisioned', async () => {
const { client, mocks } = makeClient();
const result = await buildWorkflow({
client,
...baseConfig,
buildProject: vi.fn().mockRejectedValue(new Error('no accept token')),
});
expect(result.success).toBe(false);
expect(result.seedingFailed).toBe(true);
expect(mocks.ensureThread).not.toHaveBeenCalled();
expect(mocks.sendMessage).not.toHaveBeenCalled();
});
it("tags the artifact refs with the build's project, so later reads look there", async () => {
const actual =
await vi.importActual<typeof import('../outcome/event-parser')>('../outcome/event-parser');
vi.mocked(extractOutcomeFromEvents).mockImplementationOnce((events) => ({
...actual.extractOutcomeFromEvents(events),
artifactRefs: [{ type: 'agent', id: 'agent-1' }],
}));
const { client } = makeClient();
const result = await buildWorkflow({ client, ...baseConfig, workflowExpected: false });
expect(result.artifactRefs).toEqual([
{ type: 'agent', id: 'agent-1', projectId: 'build-project' },
]);
});
});

View File

@ -253,6 +253,7 @@ describe('buildWorkflow declared credentials', () => {
'httpHeaderAuth',
{},
undefined,
undefined,
);
expect(setThreadCredentialAllowlist).toHaveBeenCalledWith(
expect.any(String),

View File

@ -92,6 +92,51 @@ describe('cleanupBuild', () => {
expect(mocks.deleteThread).toHaveBeenCalledWith('T1');
});
it("cleans the build's own project, then deletes the user that owns it", async () => {
const { client, mocks } = makeClient({
deleteAgent: vi.fn().mockResolvedValue(undefined),
deleteUser: vi.fn().mockResolvedValue(undefined),
});
const build = {
...makeBuild(),
artifactRefs: [{ type: 'agent' as const, id: 'agent-1' }],
buildProjectId: 'build-project',
buildUserId: 'build-user',
};
await expect(cleanupBuild(client, build, silentLogger)).resolves.toBe(true);
expect(mocks.deleteDataTable).toHaveBeenCalledWith('build-project', 'DT1');
expect(mocks.deleteAgent).toHaveBeenCalledWith('build-project', 'agent-1');
expect(mocks.getPersonalProjectId).not.toHaveBeenCalled();
expect(mocks.deleteUser).toHaveBeenCalledWith('build-user');
// The thread goes through its own route first, which also stops its sandbox.
expect(mocks.deleteUser.mock.invocationCallOrder[0]).toBeGreaterThan(
mocks.deleteThread.mock.invocationCallOrder[0],
);
});
it('keeps the build user when another delete fails, so the retry still finds its project', async () => {
const { client, mocks } = makeClient({
deleteDataTable: vi.fn().mockRejectedValue(new Error('HTTP 502')),
deleteUser: vi.fn().mockResolvedValue(undefined),
});
const build = { ...makeBuild(), buildProjectId: 'build-project', buildUserId: 'build-user' };
await expect(cleanupBuild(client, build, silentLogger)).resolves.toBe(false);
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
it('reports not clean when the build user cannot be deleted, so the caller can retry', async () => {
const { client } = makeClient({
deleteUser: vi.fn().mockRejectedValue(new Error('HTTP 502')),
});
const build = { ...makeBuild(), buildProjectId: 'build-project', buildUserId: 'build-user' };
await expect(cleanupBuild(client, build, silentLogger)).resolves.toBe(false);
});
it('never calls deleteAgent for a build without an agent ref', async () => {
const { client, mocks } = makeClient({ deleteAgent: vi.fn() });

View File

@ -0,0 +1,49 @@
import { vi } from 'vitest';
import { silentLogger } from './fixtures';
import type { N8nClient } from '../clients/n8n-client';
import type { Lane } from '../run/build-orchestrator';
import { cleanupLanes } from '../run/lane-setup';
import { LaneUserPool } from '../run/lane-users';
function laneWithPool() {
const deleteUser = vi.fn().mockResolvedValue(undefined);
const inviteMembers = vi.fn(
async (emails: string[]) =>
await Promise.resolve(
emails.map((email, i) => ({ id: `u${String(i + 1)}`, email, acceptToken: 'token' })),
),
);
const client = { deleteUser, inviteMembers } as unknown as N8nClient;
const buildUserPool = new LaneUserPool(client, 3);
const lane = {
client,
baseUrl: 'http://n8n.test',
preRunWorkflowIds: new Set<string>(),
claimedWorkflowIds: new Set<string>(),
createdCredentialIds: new Set<string>(),
workflowIdsToDelete: new Set<string>(),
buildUserPool,
} as Lane;
return { lane, buildUserPool, deleteUser };
}
describe('cleanupLanes', () => {
it('deletes every invited build user, claimed or not, when workflows are throwaway', async () => {
const { lane, buildUserPool, deleteUser } = laneWithPool();
await buildUserPool.claim();
await cleanupLanes([lane], { workflows: false, buildUsers: true }, silentLogger);
expect(deleteUser.mock.calls.map(([id]) => id as string)).toEqual(['u1', 'u2', 'u3']);
});
it('keeps the build users, and the workflows in their projects, under --keep-workflows', async () => {
const { lane, buildUserPool, deleteUser } = laneWithPool();
await buildUserPool.claim();
await cleanupLanes([lane], { workflows: false, buildUsers: false }, silentLogger);
expect(deleteUser).not.toHaveBeenCalled();
});
});

View File

@ -1,6 +1,11 @@
import { silentLogger } from './fixtures';
import type { N8nClient } from '../clients/n8n-client';
import { cleanupLaneUsers, LaneUserPool, provisionCaseBuildUser } from '../run/lane-users';
import {
cleanupLaneUsers,
LaneUserPool,
provisionBuildProject,
provisionCaseBuildUser,
} from '../run/lane-users';
import type { TestCaseCredential } from '../types';
type InvitedUser = { id: string; email: string; acceptToken: string };
@ -172,6 +177,31 @@ describe('provisionCaseBuildUser', () => {
});
});
describe('provisionBuildProject', () => {
it("accepts a pooled invite under the given name and returns that user's personal project", async () => {
const { client: ownerClient, inviteBatches } = fakeOwnerClient();
const pool = new LaneUserPool(ownerClient, 1);
const member = {
acceptInvitation: vi.fn().mockResolvedValue(undefined),
getPersonalProjectId: vi.fn().mockResolvedValue('member-project'),
};
const project = await provisionBuildProject({
pool,
memberClient: member as unknown as N8nClient,
name: { firstName: 'Eval', lastName: 'Bot' },
});
expect(member.acceptInvitation).toHaveBeenCalledWith({
token: `token-${inviteBatches[0][0]}`,
firstName: 'Eval',
lastName: 'Bot',
password: pool.password,
});
expect(project).toEqual({ userId: 'u1', projectId: 'member-project' });
});
});
describe('cleanupLaneUsers', () => {
it('deletes every invited user best-effort, surviving individual failures', async () => {
const { client: ownerClient } = fakeOwnerClient();

View File

@ -907,6 +907,7 @@ describe('UserProxyLlm.respondToConfirmation', () => {
'httpTemplatedCustomAuth',
expect.objectContaining({ template: expect.stringContaining('Bearer') }),
undefined,
undefined,
);
// Filling the slot means the credential works, so its test is bypassed.
expect(setThreadCredentialAllowlist).toHaveBeenCalledWith(
@ -956,6 +957,7 @@ describe('UserProxyLlm.respondToConfirmation', () => {
'slackApi',
expect.any(Object),
undefined,
undefined,
);
// The allowlist call must include the pre-existing id, not just the new
// one — setThreadCredentialAllowlist replaces the whole list.
@ -1137,6 +1139,42 @@ describe('UserProxyLlm.respondToConfirmation', () => {
expect(proxy.getDecisionStats()['credential-test-bypassed']).toBeUndefined();
});
it("workflows(action='setup'): creates the credential in the build's project", async () => {
const agent = new FakeAgent();
agent.enqueue({
action: 'apply_setup_wizard',
nodeParametersJson: '{}',
nodeCredentialsJson: JSON.stringify({ 'Post To Slack': { slackApi: 'new' } }),
});
const { client, createCredential } = fakeCredentialClient('cred-fresh');
const proxy = new UserProxyLlm({
conversation: [
{ role: 'user', text: 'Post to Slack every morning.' },
{ role: 'user', text: '[Set up the Slack credential now.]' },
],
agent,
credentialCreation: {
client,
threadId: 'thread-1',
allowlistedCredentialIds: [],
projectId: 'build-project',
},
});
await proxy.respondToConfirmation(
setupWizardEvent('req-sw-build-project', [
{
nodeId: 'n1',
nodeName: 'Post To Slack',
credentialType: 'slackApi',
existingCredentials: [],
},
]),
);
expect(createCredential.mock.calls[0]?.[4]).toBe('build-project');
});
it("workflows(action='setup'): keeps the seeded credentials bypassed when it creates another", async () => {
const agent = new FakeAgent();
agent.enqueue({
@ -1527,6 +1565,7 @@ describe('UserProxyLlm.respondToConfirmation', () => {
'slackApi',
expect.any(Object),
undefined,
undefined,
);
expect(setThreadCredentialAllowlist).toHaveBeenCalledWith('thread-1', ['cred-fresh'], []);
expect(response.kind).toBe('credentialSelection');

View File

@ -179,7 +179,11 @@ async function main(): Promise<void> {
experimentName: args.experimentName,
});
} finally {
await cleanupLanes(lanes, cleanupBuiltWorkflows, logger);
await cleanupLanes(
lanes,
{ workflows: cleanupBuiltWorkflows, buildUsers: !args.keepWorkflows },
logger,
);
}
}

View File

@ -275,6 +275,9 @@ export class N8nClient {
* never logged in takes the strict path. */
private hasGlobalWorkflowDelete = false;
/** The logged-in user's name, when the login payload carries both parts. */
userName?: { firstName: string; lastName: string };
/** Public: the browser runtime needs to know where n8n ACTUALLY is, which is
* not always what n8n reports as its own base URL (see `planRelayConnection`). */
constructor(readonly baseUrl: string) {}
@ -293,13 +296,22 @@ export class N8nClient {
const result = (await this.fetch('/rest/login', {
method: 'POST',
body: { emailOrLdapLoginId: loginEmail, password: loginPassword },
})) as { data?: { globalScopes?: string[]; isOwner?: boolean } };
})) as {
data?: {
globalScopes?: string[];
isOwner?: boolean;
firstName?: string | null;
lastName?: string | null;
};
};
// `/rest/login` returns the public user with `withScopes: true`. `isOwner`
// is the fallback for a payload that carries no scope list: an owner always
// holds the scope, so the two agree wherever both are present.
this.hasGlobalWorkflowDelete =
result.data?.globalScopes?.includes('workflow:delete') ?? result.data?.isOwner ?? false;
const { firstName, lastName } = result.data ?? {};
this.userName = firstName && lastName ? { firstName, lastName } : undefined;
if (!this.sessionCookie) {
throw new Error('Failed to authenticate with n8n — no session cookie received');
@ -813,18 +825,25 @@ export class N8nClient {
}
/**
* Create a credential.
* POST /rest/credentials body: { name, type, data }
* Create a credential, in `projectId` when given (else the caller's personal project).
* POST /rest/credentials body: { name, type, data, projectId? }
*/
async createCredential(
name: string,
type: string,
data: Record<string, unknown>,
description?: string | null,
projectId?: string,
): Promise<{ id: string }> {
const result = (await this.fetch('/rest/credentials', {
method: 'POST',
body: { name, type, data, ...(description !== undefined ? { description } : {}) },
body: {
name,
type,
data,
...(description !== undefined ? { description } : {}),
...(projectId !== undefined ? { projectId } : {}),
},
})) as { data: { id: string } };
return { id: result.data.id };
}

View File

@ -180,6 +180,7 @@ describe('credential descriptions', () => {
'httpTemplatedCustomAuth',
expect.any(Object),
'Production reports',
undefined,
);
});
});

View File

@ -165,6 +165,8 @@ export async function createOneCredential(
description?: string | null;
/** Seed with no field values, modelling a credential the user saved empty. */
blank?: boolean;
/** The build's project. Absent: the caller's personal project. */
projectId?: string;
},
): Promise<CreatedCredential> {
if (credentialType === 'httpTemplatedCustomAuth') {
@ -194,6 +196,7 @@ export async function createOneCredential(
credentialType,
options?.blank ? {} : template.buildData(token),
options?.description,
options?.projectId,
);
return { id, name: resolvedName, type: credentialType };
}
@ -213,6 +216,7 @@ async function createTemplatedCustomAuthCredential(
logger?: EvalLogger;
setupHint?: InstanceAiCredentialSetupHint;
description?: string | null;
projectId?: string;
},
): Promise<CreatedCredential> {
const hint = options?.setupHint;
@ -259,6 +263,7 @@ async function createTemplatedCustomAuthCredential(
acceptedStatusCodes: hint.acceptedStatusCodes ? JSON.stringify(hint.acceptedStatusCodes) : '',
},
options?.description,
options?.projectId,
);
return { id, name: resolvedName, type: 'httpTemplatedCustomAuth' };
}
@ -286,6 +291,8 @@ export async function createDeclaredCredentials(
onCreated?: (id: string) => void;
logger?: EvalLogger;
nameCounts?: Map<string, number>;
/** The build's project. Absent: the caller's personal project. */
projectId?: string;
},
): Promise<CreatedCredential[]> {
const logger = options?.logger;
@ -297,6 +304,7 @@ export async function createDeclaredCredentials(
logger,
...(decl.description !== undefined ? { description: decl.description } : {}),
...(decl.blank ? { blank: true } : {}),
...(options?.projectId !== undefined ? { projectId: options.projectId } : {}),
});
options?.onCreated?.(cred.id);
created.push(cred);

View File

@ -170,6 +170,8 @@ export async function executeAgentScenario(
buildTrace?: BuildTrace,
outputDir?: string,
seedContext?: ScenarioSeedContext,
/** The build's project. Absent: the client's personal project. */
projectId?: string,
): Promise<ExecutionScenarioResult> {
if (seedContext) {
await reseedScenarioTables(
@ -182,10 +184,10 @@ export async function executeAgentScenario(
}
const execStart = Date.now();
const projectId = await client.getPersonalProjectId();
const agentProjectId = projectId ?? (await client.getPersonalProjectId());
let evalResult = await client.executeAgentWithLlmMock(
agentId,
projectId,
agentProjectId,
scenario.dataSetup,
timeoutMs,
);
@ -203,7 +205,7 @@ export async function executeAgentScenario(
await delay(500 * attempt);
evalResult = await client.executeAgentWithLlmMock(
agentId,
projectId,
agentProjectId,
scenario.dataSetup,
timeoutMs,
);

View File

@ -17,8 +17,8 @@ export const agentHandler: ArtifactHandler<AgentArtifact> = {
return ctx.artifactRefs.filter((ref) => ref.type === 'agent');
},
async fetch(ref, client) {
// Agent routes are project-scoped; the harness builds in the user's personal project.
const projectId = await client.getPersonalProjectId();
// Agent routes are project-scoped.
const projectId = ref.projectId ?? (await client.getPersonalProjectId());
const [config, skills] = await Promise.all([
client.getAgentConfig(projectId, ref.id),
client.getAgentSkills(projectId, ref.id),

View File

@ -23,7 +23,7 @@ export const configEvalHandler: ArtifactHandler<ConfigEvalArtifact> = {
let dataTable: ConfigEvalArtifact['dataTable'];
if (dtConfig?.datasetSource === 'data_table') {
// re-check narrows the discriminated union -- .find() alone doesn't
const projectId = await client.getPersonalProjectId();
const projectId = ref.projectId ?? (await client.getPersonalProjectId());
const dataTableId = dtConfig.datasetRef.dataTableId;
const [columns, rows] = await Promise.all([
client.getDataTableColumns(projectId, dataTableId),

View File

@ -163,6 +163,8 @@ interface MultiTurnDriverConfig {
/** Shared with `createDeclaredCredentials`'s pre-run seeding — see
* `CredentialCreationConfig.nameCounts`. */
credentialNameCounts?: Map<string, number>;
/** The build's project, where a mid-run credential is created. */
credentialProjectId?: string;
/** Resource references sent with the FIRST message only — an attachment is a
* hand-off, not something a user re-sends every turn. */
openingAttachments?: InstanceAiResourceAttachment[];
@ -203,6 +205,7 @@ async function driveMultiTurnConversation(
bypassCredentialTestIds: config.bypassCredentialTestIds,
createdCredentialIds: config.createdCredentialIds,
nameCounts: config.credentialNameCounts,
projectId: config.credentialProjectId,
},
}
: {}),
@ -286,6 +289,9 @@ export interface BuildResult {
* a regression ever did let the agent write into one, an early delete would
* destroy the workflow under grading and read as a build failure. */
createdProjectIds?: string[];
/** The build's own project; `cleanupBuild` deletes the user that owns it. */
buildProjectId?: string;
buildUserId?: string;
/** The ROOT folders a seed created in the thread's project (a folder delete
* cascades to its subfolders). Deleted in `cleanupBuild` after the workflows,
* because a folder delete archives what it holds. */
@ -499,6 +505,8 @@ export interface BuildWorkflowConfig {
credentials?: TestCaseCredential[];
/** Run-level registry the created credential IDs are added to for cleanup. */
createdCredentialIds?: Set<string>;
/** Gives the build a fresh project. Absent: the owner's personal project. */
buildProject?: () => Promise<{ userId: string; projectId: string }>;
/** History restored before the live message — carried in the case
* (`mode: 'inline'`) or reconstructed from a trace (`mode: 'replay'`, which
* also supplies the live turn). */
@ -585,6 +593,12 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
/** Projects this run created, torn down after it — instance-level, so they
* outlive the thread and would otherwise pile up across runs. */
const seededProjectIds: string[] = [];
let buildProject: { userId: string; projectId: string } | undefined;
// On every return path, so cleanup deletes the build user even after a failure.
const buildProjectFields = () =>
buildProject
? { buildProjectId: buildProject.projectId, buildUserId: buildProject.userId }
: {};
/** The agent the seeded history last targeted — graded and executed first. */
let seedActiveAgentId: string | undefined;
// Scenario seed tables are created empty before the build
@ -736,7 +750,17 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
` Running case${isMultiTurn ? ' [multi-turn]' : ''}: "${truncate(openingMessage, 60)}"${config.laneTag ?? ''}`,
);
const projectId = await client.getPersonalProjectId();
if (config.buildProject) {
try {
buildProject = await config.buildProject();
} catch (error: unknown) {
seedingFailed = true;
throw new Error(
`Build project setup failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
const projectId = buildProject?.projectId ?? (await client.getPersonalProjectId());
await client.ensureThread(
threadId,
projectId,
@ -760,6 +784,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
onCreated: (id) => config.createdCredentialIds?.add(id),
logger,
nameCounts: credentialNameCounts,
projectId: buildProject?.projectId,
});
const seededCredentialIds = createdCredentials.map((c) => c.id);
// `createDeclaredCredentials` returns one entry per `declaredCredentials`, in
@ -1163,6 +1188,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
bypassCredentialTestIds,
createdCredentialIds: config.createdCredentialIds,
credentialNameCounts,
credentialProjectId: buildProject?.projectId,
}
: {}),
// The pre-seeded-table note goes to the agent, but the recorded turn
@ -1254,12 +1280,16 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
seedActiveAgentId && restoredAgentIds.includes(seedActiveAgentId)
? [seedActiveAgentId, ...restoredAgentIds.filter((id) => id !== seedActiveAgentId)]
: restoredAgentIds;
const artifactRefs: ArtifactRef[] = [
const builtRefs: ArtifactRef[] = [
...eventOutcome.artifactRefs,
...restoredAgentOrder
.filter((id) => !seenAgentIds.has(id))
.map((id) => ({ type: 'agent' as const, id })),
];
const buildProjectId = buildProject?.projectId;
const artifactRefs = buildProjectId
? builtRefs.map((ref) => ({ ...ref, projectId: buildProjectId }))
: builtRefs;
const buildTrace: BuildTrace = {
finalText:
eventOutcome.finalText.length > 0 ? eventOutcome.finalText : lastAgentText(transcript),
@ -1304,6 +1334,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
createdDataTableIds: [...outcome.dataTablesCreated, ...restoredDataTableIds],
createdAgentIds: restoredAgentIds,
createdProjectIds: seededProjectIds,
...buildProjectFields(),
createdFolderIds: restoredFolderIds,
conversationMetrics,
events,
@ -1330,6 +1361,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
createdDataTableIds: [...outcome.dataTablesCreated, ...restoredDataTableIds],
createdAgentIds: restoredAgentIds,
createdProjectIds: seededProjectIds,
...buildProjectFields(),
createdFolderIds: restoredFolderIds,
seededScenarioTableIdsByName: scenarioTableIdsByName,
artifactRefs,
@ -1379,6 +1411,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
createdDataTableIds: [...outcome.dataTablesCreated, ...restoredDataTableIds],
createdAgentIds: restoredAgentIds,
createdProjectIds: seededProjectIds,
...buildProjectFields(),
createdFolderIds: restoredFolderIds,
seededScenarioTableIdsByName: scenarioTableIdsByName,
artifactRefs,
@ -1403,6 +1436,7 @@ export async function buildWorkflow(config: BuildWorkflowConfig): Promise<BuildR
createdDataTableIds: [...restoredDataTableIds, ...builtDataTableIds],
createdAgentIds: restoredAgentIds,
createdProjectIds: seededProjectIds,
...buildProjectFields(),
createdFolderIds: restoredFolderIds,
conversationMetrics,
events,

View File

@ -158,7 +158,7 @@ export async function cleanupBuild(
): Promise<boolean> => {
let ok = true;
try {
const projectId = await client.getPersonalProjectId();
const projectId = build.buildProjectId ?? (await client.getPersonalProjectId());
for (const id of ids) {
try {
await remove(projectId, id);
@ -239,6 +239,16 @@ export async function cleanupBuild(
}
}
// Last, and only once the rest is gone: the user's delete takes its project, so a
// retry of a failed delete needs the project to still exist.
if (build.buildUserId && clean) {
try {
await client.deleteUser(build.buildUserId);
} catch (error: unknown) {
if (!(error instanceof N8nApiError && error.status === 404)) clean = false;
}
}
return clean;
}

View File

@ -96,9 +96,8 @@ export interface Lane {
* per-lane because prebuilt/MCP-built workflows only exist on their own lane —
* deleting them via another lane's client would 404. */
workflowIdsToDelete: Set<string>;
/** Pool of invited member users for `--build-via-mcp` — each build claims one
* so its MCP credential/workflow view is isolated to that user. */
mcpUserPool?: LaneUserPool;
/** Invited members, one per build: MCP builds act as it, orchestrator builds use its project. */
buildUserPool?: LaneUserPool;
}
/** One `claude` build's Anthropic spend (`--build-via-mcp` only). Mirrors
@ -164,6 +163,7 @@ export interface LaneState {
timeoutMs: number;
testCaseName?: string;
seedContext?: ScenarioSeedContext;
projectId?: string;
}) => Promise<Awaited<ReturnType<typeof executeAgentScenario>>>;
}
@ -211,14 +211,14 @@ async function buildWorkflowViaMcpOnLane(config: {
createdWorkflowIds: [],
createdDataTableIds: [],
});
if (!lane.mcpUserPool) {
return failure(`Lane ${lane.baseUrl} has no MCP build user pool — cannot build via MCP`);
if (!lane.buildUserPool) {
return failure(`Lane ${lane.baseUrl} has no build user pool — cannot build via MCP`);
}
let mcpApiKey: string;
try {
mcpApiKey = await provisionCaseBuildUser({
pool: lane.mcpUserPool,
pool: lane.buildUserPool,
memberClient: new N8nClient(lane.baseUrl),
credentials,
onCredentialCreated: (id) => lane.createdCredentialIds.add(id),

View File

@ -426,6 +426,7 @@ export function createCasePipeline(deps: CasePipelineDeps): CasePipeline {
),
testCaseName: inputs.testCaseFile,
seedContext,
projectId: agentRef.projectId,
});
break;
} catch (error: unknown) {

View File

@ -24,7 +24,9 @@ import {
} from './build-orchestrator';
import { createCasePipeline, type CasePipeline } from './case-pipeline';
import { LaneAllocator } from './lane-allocator';
import { provisionBuildProject } from './lane-users';
import type { CliArgs } from '../cli/args';
import { N8nClient } from '../clients/n8n-client';
import type { WorkflowTestCaseWithFile } from '../data/workflows';
import { executeAgentScenario, type AgentScenarioContext } from '../harness/agent-execution';
import {
@ -135,6 +137,16 @@ export function createEvalSession(config: EvalSessionConfig): EvalSession {
const laneStates: LaneState[] = lanes.map((lane, idx) => {
const laneNum = idx + 1;
const laneTag = lanes.length > 1 ? ` [lane ${String(laneNum)}/${String(lanes.length)}]` : '';
const { buildUserPool } = lane;
// Named like the owner, so the agent's project line reads as it does for a real user.
const buildProject = buildUserPool
? async () =>
await provisionBuildProject({
pool: buildUserPool,
memberClient: new N8nClient(lane.baseUrl),
name: lane.client.userName ?? { firstName: 'Eval', lastName: 'Builder' },
})
: undefined;
return {
runner: lane,
laneNum,
@ -160,6 +172,7 @@ export function createEvalSession(config: EvalSessionConfig): EvalSession {
seed: buildArgs.seed,
executionScenarios: buildArgs.executionScenarios,
createdCredentialIds: lane.createdCredentialIds,
buildProject,
timeoutMs: buildArgs.timeoutMs,
preRunWorkflowIds: lane.preRunWorkflowIds,
preRunDataTableIds: lane.preRunDataTableIds,
@ -212,6 +225,7 @@ export function createEvalSession(config: EvalSessionConfig): EvalSession {
timeoutMs: number;
testCaseName?: string;
seedContext?: ScenarioSeedContext;
projectId?: string;
}) =>
await executeAgentScenario(
lane.client,
@ -224,6 +238,7 @@ export function createEvalSession(config: EvalSessionConfig): EvalSession {
execArgs.buildTrace,
args.outputDir,
execArgs.seedContext,
execArgs.projectId,
),
),
};

View File

@ -41,14 +41,12 @@ export async function setupLanes(args: CliArgs, logger: EvalLogger): Promise<Lan
logger.info(`Skipped MCP registry seed (test endpoint unavailable)${tag}`);
}
// --build-via-mcp: enable MCP and set up the lane's build-user pool.
// Each lane is a self-contained build+verify target — a workflow built
// here is verified here, so N lanes parallelize the whole pipeline.
let mcpUserPool: LaneUserPool | undefined;
if (args.buildViaMcp) {
await client.enableMcpAccess();
mcpUserPool = new LaneUserPool(client);
}
const buildUserPool = new LaneUserPool(client);
// Independent reads of one instance, so they run together.
const [preRunWorkflowIds, preRunDataTableIds, preRunFolderIds] = await Promise.all([
@ -68,7 +66,7 @@ export async function setupLanes(args: CliArgs, logger: EvalLogger): Promise<Lan
claimedWorkflowIds,
createdCredentialIds,
workflowIdsToDelete,
mcpUserPool,
buildUserPool,
};
}),
);
@ -81,19 +79,19 @@ export async function setupLanes(args: CliArgs, logger: EvalLogger): Promise<Lan
* workflows across lanes; a single-lane cleanup would 404 on the rest). */
export async function cleanupLanes(
lanes: Lane[],
cleanupBuiltWorkflows: boolean,
cleanup: { workflows: boolean; buildUsers: boolean },
logger: EvalLogger,
): Promise<void> {
await Promise.all(
lanes.map(async (lane) => {
if (cleanupBuiltWorkflows && lane.workflowIdsToDelete.size > 0) {
if (cleanup.workflows && lane.workflowIdsToDelete.size > 0) {
await cleanupPrebuiltWorkflows(lane.client, lane.workflowIdsToDelete, logger);
}
await cleanupCredentials(lane.client, [...lane.createdCredentialIds]).catch(() => {});
// Deleting a user deletes their remaining data, so keep the build
// users when workflows are kept.
if (cleanupBuiltWorkflows && lane.mcpUserPool) {
await cleanupLaneUsers(lane.client, lane.mcpUserPool, logger);
if (cleanup.buildUsers && lane.buildUserPool) {
await cleanupLaneUsers(lane.client, lane.buildUserPool, logger);
}
}),
);

View File

@ -1,10 +1,7 @@
// ---------------------------------------------------------------------------
// Per-case build users for `--build-via-mcp`.
//
// MCP credential/workflow visibility is user-scoped, so running each build as
// its own freshly-invited member gives it an isolated credential view holding
// exactly the case's declared credentials — the MCP analog of the
// orchestrator's per-thread credential pinning.
// Per-build users. `--build-via-mcp` builds act as a fresh member; orchestrator
// builds keep the owner but run in a fresh member's personal project, the only
// new project an unlicensed instance can create.
// ---------------------------------------------------------------------------
import { randomBytes } from 'crypto';
@ -58,7 +55,7 @@ export class LaneUserPool {
// One batched POST per chunk — /rest/invitations is IP-rate-limited in production.
const emails = Array.from({ length: this.chunkSize }, () => {
this.seq += 1;
return `eval-mcp-${this.nonce}-${String(this.seq)}@n8n-evals.invalid`;
return `eval-build-${this.nonce}-${String(this.seq)}@n8n-evals.invalid`;
});
const invited = await this.ownerClient.inviteMembers(emails);
// Record every shell before judging usability: n8n creates them all up
@ -108,6 +105,30 @@ export async function provisionCaseBuildUser(opts: {
return mcpApiKey;
}
export interface BuildProject {
userId: string;
projectId: string;
}
/** Accept a pooled invite and return that user's personal project, so a build
* starts in a project no other build has touched. */
export async function provisionBuildProject(opts: {
pool: LaneUserPool;
/** A fresh, logged-out client for the lane — accepting the invite logs it in. */
memberClient: N8nClient;
/** Becomes the project name the agent sees. */
name: { firstName: string; lastName: string };
}): Promise<BuildProject> {
const { pool, memberClient } = opts;
const user = await pool.claim();
await memberClient.acceptInvitation({
token: user.acceptToken,
...opts.name,
password: pool.password,
});
return { userId: user.id, projectId: await memberClient.getPersonalProjectId() };
}
/** Best-effort deletion of the pool's users. Deleting a user also deletes
* what's left in their personal project, so only call this when built
* workflows are throwaway. */
@ -129,5 +150,5 @@ export async function cleanupLaneUsers(
// best-effort
}
}
logger.verbose(`Deleted ${String(deleted)}/${String(ids.length)} MCP build user(s)`);
logger.verbose(`Deleted ${String(deleted)}/${String(ids.length)} build user(s)`);
}

View File

@ -202,6 +202,8 @@ export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
export interface ArtifactRef {
type: ArtifactType;
id: string;
/** The project the build ran in. Absent means the owner's personal project. */
projectId?: string;
}
/** Structured agent preview. Capture redacts it; persistence validates and caps it. */

View File

@ -51,6 +51,8 @@ export interface CredentialCreationConfig {
* credentials with no way to tell which one an agent picked). Defaults to
* a fresh, unshared `Map` if omitted. */
nameCounts?: Map<string, number>;
/** The build's project. Absent: the client's personal project. */
projectId?: string;
}
/**
@ -272,13 +274,13 @@ export class UserProxyLlm {
// throw here means that invariant broke, not a normal runtime failure.
throw new Error('createCredential invoked without a credentialCreation config');
}
const { client, threadId, createdCredentialIds } = this.credentialCreation;
const { client, threadId, createdCredentialIds, projectId } = this.credentialCreation;
const created = await createOneCredential(
client,
credentialType,
undefined,
this.createdCredentialNameCounts,
{ logger: this.logger, setupHint: options?.setupHint },
{ logger: this.logger, setupHint: options?.setupHint, projectId },
);
createdCredentialIds?.add(created.id);
this.allowlistedCredentialIds = [...this.allowlistedCredentialIds, created.id];