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
30 changes: 30 additions & 0 deletions src/server/lib/agentSession/__tests__/configSeeder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,36 @@ describe('configSeeder', () => {
expect(script).toContain('"/workspace/repos/org/api"');
});

it('clones multiple repositories in parallel before continuing to installs', () => {
const script = generateInitScript({
workspaceRepos: [
{
repo: 'org/ui',
repoUrl: 'https://github.com/org/ui.git',
branch: 'feature/ui',
revision: null,
mountPath: '/workspace/repos/org/ui',
primary: true,
},
{
repo: 'org/api',
repoUrl: 'https://github.com/org/api.git',
branch: 'feature/api',
revision: null,
mountPath: '/workspace/repos/org/api',
primary: false,
},
],
installCommand: 'pnpm install',
});

expect(script).toContain('clone_pids=""');
expect(script).toContain('clone_pids="$clone_pids $!"');
expect(script).toContain('for clone_pid in $clone_pids; do');
expect(script).toContain(' wait "$clone_pid"');
expect(script.indexOf('for clone_pid in $clone_pids; do')).toBeLessThan(script.indexOf('pnpm install'));
});

it('starts with shebang', () => {
const script = generateInitScript(baseOpts);
expect(script.startsWith('#!/bin/sh')).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* Copyright 2026 GoodRx, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

const mockSetupReadOnlyServiceAccountInNamespace = jest.fn();

jest.mock('server/lib/kubernetes/rbac', () => ({
setupReadOnlyServiceAccountInNamespace: mockSetupReadOnlyServiceAccountInNamespace,
}));

function loadModule() {
let loadedModule: typeof import('../serviceAccountFactory');
jest.isolateModules(() => {
loadedModule = require('../serviceAccountFactory');
});

return loadedModule!;
}

describe('serviceAccountFactory', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('reuses the in-flight setup promise for the same namespace', async () => {
let resolveSetup!: () => void;
mockSetupReadOnlyServiceAccountInNamespace.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveSetup = resolve;
})
);

const { ensureAgentSessionServiceAccount } = loadModule();
const firstCall = ensureAgentSessionServiceAccount('test-ns');
const secondCall = ensureAgentSessionServiceAccount('test-ns');

expect(mockSetupReadOnlyServiceAccountInNamespace).toHaveBeenCalledTimes(1);

resolveSetup();

await expect(firstCall).resolves.toBe('agent-sa');
await expect(secondCall).resolves.toBe('agent-sa');
});

it('clears the namespace cache after a failed setup', async () => {
const setupError = new Error('setup failed');
mockSetupReadOnlyServiceAccountInNamespace.mockRejectedValueOnce(setupError).mockResolvedValueOnce(undefined);

const { ensureAgentSessionServiceAccount } = loadModule();

await expect(ensureAgentSessionServiceAccount('test-ns')).rejects.toThrow('setup failed');
await expect(ensureAgentSessionServiceAccount('test-ns')).resolves.toBe('agent-sa');
expect(mockSetupReadOnlyServiceAccountInNamespace).toHaveBeenCalledTimes(2);
});
});
61 changes: 39 additions & 22 deletions src/server/lib/agentSession/configSeeder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ export interface InitScriptOpts {
useGitHubToken?: boolean;
}

function buildRepoCloneLines(repo: AgentSessionWorkspaceRepo): string[] {
const parentDir = pathPosix.dirname(repo.mountPath);
const cloneRoot = parentDir === '/' ? repo.mountPath : parentDir;
const lines = [
`mkdir -p "${escapeDoubleQuotedShell(cloneRoot)}"`,
`git clone --progress --depth 50 --branch "${escapeDoubleQuotedShell(
repo.branch
)}" --single-branch "${escapeDoubleQuotedShell(repo.repoUrl)}" "${escapeDoubleQuotedShell(repo.mountPath)}"`,
`cd "${escapeDoubleQuotedShell(repo.mountPath)}"`,
];

if (!repo.revision) {
return lines;
}

lines.push(
`if ! git rev-parse --verify --quiet "${escapeDoubleQuotedShell(repo.revision)}^{commit}" >/dev/null; then`,
` git fetch --unshallow origin "${escapeDoubleQuotedShell(
repo.branch
)}" || git fetch origin "${escapeDoubleQuotedShell(repo.branch)}"`,
'fi',
`git checkout "${escapeDoubleQuotedShell(repo.revision)}"`
);

return lines;
}

function escapeDoubleQuotedShell(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
}
Expand Down Expand Up @@ -139,30 +166,20 @@ export function generateInitScript(opts: InitScriptOpts): string {

appendGitIdentityAndAuthLines(lines, opts);

for (const repo of workspaceRepos) {
const parentDir = pathPosix.dirname(repo.mountPath);
const cloneRoot = parentDir === '/' ? repo.mountPath : parentDir;
lines.push(
'',
`mkdir -p "${escapeDoubleQuotedShell(cloneRoot)}"`,
`git clone --progress --depth 50 --branch "${escapeDoubleQuotedShell(
repo.branch
)}" --single-branch "${escapeDoubleQuotedShell(repo.repoUrl)}" "${escapeDoubleQuotedShell(repo.mountPath)}"`,
`cd "${escapeDoubleQuotedShell(repo.mountPath)}"`
);

if (!repo.revision) {
continue;
if (workspaceRepos.length === 1) {
lines.push('', ...buildRepoCloneLines(workspaceRepos[0]));
} else {
lines.push('', 'clone_pids=""');

for (const repo of workspaceRepos) {
lines.push('(', ' set -e');
for (const repoLine of buildRepoCloneLines(repo)) {
lines.push(` ${repoLine}`);
}
lines.push(') &', 'clone_pids="$clone_pids $!"');
}

lines.push(
`if ! git rev-parse --verify --quiet "${escapeDoubleQuotedShell(repo.revision)}^{commit}" >/dev/null; then`,
` git fetch --unshallow origin "${escapeDoubleQuotedShell(
repo.branch
)}" || git fetch origin "${escapeDoubleQuotedShell(repo.branch)}"`,
'fi'
);
lines.push(`git checkout "${escapeDoubleQuotedShell(repo.revision)}"`);
lines.push('for clone_pid in $clone_pids; do', ' wait "$clone_pid"', 'done');
}

if (installCommand) {
Expand Down
8 changes: 4 additions & 4 deletions src/server/lib/agentSession/podFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,8 +632,8 @@ export function buildSessionWorkspacePodSpec(opts: SessionWorkspacePodOptions):
path: '/healthz',
port: SESSION_WORKSPACE_EDITOR_PORT,
},
initialDelaySeconds: 2,
periodSeconds: 5,
initialDelaySeconds: 1,
periodSeconds: 2,
},
volumeMounts: [
workspaceVolumeMount,
Expand Down Expand Up @@ -682,8 +682,8 @@ export function buildSessionWorkspacePodSpec(opts: SessionWorkspacePodOptions):
path: '/health',
port: SESSION_WORKSPACE_GATEWAY_PORT,
},
initialDelaySeconds: 2,
periodSeconds: 5,
initialDelaySeconds: 1,
periodSeconds: 2,
},
volumeMounts: [
workspaceVolumeMount,
Expand Down
21 changes: 19 additions & 2 deletions src/server/lib/agentSession/serviceAccountFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,25 @@
import { setupReadOnlyServiceAccountInNamespace } from 'server/lib/kubernetes/rbac';

export const AGENT_SESSION_SERVICE_ACCOUNT_NAME = 'agent-sa';
const serviceAccountSetupByNamespace = new Map<string, Promise<string>>();

export async function ensureAgentSessionServiceAccount(namespace: string): Promise<string> {
await setupReadOnlyServiceAccountInNamespace(namespace, AGENT_SESSION_SERVICE_ACCOUNT_NAME);
return AGENT_SESSION_SERVICE_ACCOUNT_NAME;
let setupPromise = serviceAccountSetupByNamespace.get(namespace);
if (!setupPromise) {
setupPromise = (async () => {
await setupReadOnlyServiceAccountInNamespace(namespace, AGENT_SESSION_SERVICE_ACCOUNT_NAME);
return AGENT_SESSION_SERVICE_ACCOUNT_NAME;
})();
serviceAccountSetupByNamespace.set(namespace, setupPromise);
}

try {
return await setupPromise;
} catch (error) {
if (serviceAccountSetupByNamespace.get(namespace) === setupPromise) {
serviceAccountSetupByNamespace.delete(namespace);
}

throw error;
}
}
96 changes: 96 additions & 0 deletions src/server/services/__tests__/agentSandboxSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ import { BuildEnvironmentVariables } from 'server/lib/buildEnvVariables';
import { fetchLifecycleConfig, getDeployingServicesByName } from 'server/models/yaml';
import { BuildStatus, BuildKind } from 'shared/constants';

function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});

return {
promise,
resolve,
reject,
};
}

describe('agentSandboxSession', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -216,6 +231,87 @@ describe('agentSandboxSession', () => {
expect(selected.map((item: any) => item.name)).toEqual(['frontend', 'worker']);
});

it('resolves sandbox candidates in parallel', async () => {
const service = new AgentSandboxSessionService({} as any, {} as any, {} as any, {} as any);
const frontendSource = createDeferred<any>();
const workerSource = createDeferred<any>();

jest
.spyOn(service as any, 'resolveServiceSource')
.mockImplementationOnce(() => frontendSource.promise)
.mockImplementationOnce(() => workerSource.promise);

const resolvePromise = (service as any).resolveCandidateServices(
{
uuid: 'base-build',
deploys: [
{
id: 1,
active: true,
deployable: { name: 'frontend' },
repository: { fullName: 'example-org/frontend' },
branchName: 'main',
},
{
id: 2,
active: true,
deployable: { name: 'worker' },
repository: { fullName: 'example-org/worker' },
branchName: 'main',
},
],
} as any,
{
environment: {
defaultServices: [{ name: 'frontend' }, { name: 'worker' }],
optionalServices: [],
},
} as any,
{
repo: 'example-org/environment',
branch: 'main',
}
);

await new Promise((resolve) => setImmediate(resolve));

expect((service as any).resolveServiceSource).toHaveBeenCalledTimes(2);

frontendSource.resolve({
repo: 'example-org/frontend',
branch: 'main',
yamlService: {
name: 'frontend',
dev: { image: 'node:20', command: 'pnpm dev' },
github: {
docker: {
app: {
dockerfilePath: 'Dockerfile',
},
},
},
},
});
workerSource.resolve({
repo: 'example-org/worker',
branch: 'main',
yamlService: {
name: 'worker',
dev: { image: 'node:20', command: 'pnpm start' },
github: {
docker: {
app: {
dockerfilePath: 'Dockerfile',
},
},
},
},
});

const candidates = await resolvePromise;
expect(candidates.map((candidate: any) => candidate.name)).toEqual(['frontend', 'worker']);
});

it('maps selected services to cloned sandbox deploys by base deploy id', () => {
const service = new AgentSandboxSessionService({} as any, {} as any, {} as any, {} as any);
const selectedService = {
Expand Down
25 changes: 25 additions & 0 deletions src/server/services/__tests__/agentSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const mockGetCompatibleReadyPrewarm = jest.fn();
const mockGetReadyPrewarmByPvc = jest.fn();
const mockExecInPod = jest.fn();
const mockResolveSessionPodServersForRepo = jest.fn().mockResolvedValue([]);
const mockGetDefaultThreadForSession = jest.fn().mockResolvedValue({ uuid: 'default-thread-1' });

jest.mock('server/models/AgentSession');
jest.mock('server/models/Build');
Expand Down Expand Up @@ -86,6 +87,12 @@ jest.mock('server/services/agentPrewarm', () => ({
getReadyPrewarmByPvc: mockGetReadyPrewarmByPvc,
})),
}));
jest.mock('server/services/agent/ThreadService', () => ({
__esModule: true,
default: {
getDefaultThreadForSession: mockGetDefaultThreadForSession,
},
}));
jest.mock('server/lib/nativeHelm/helm', () => ({
deployHelm: jest.fn().mockResolvedValue(undefined),
}));
Expand Down Expand Up @@ -408,6 +415,7 @@ describe('AgentSessionService', () => {
mockedBuildServiceModule.deleteBuild.mockResolvedValue(undefined);
mockGetCompatibleReadyPrewarm.mockResolvedValue(null);
mockGetReadyPrewarmByPvc.mockResolvedValue(null);
mockGetDefaultThreadForSession.mockResolvedValue({ uuid: 'default-thread-1' });
mockExecInPod.mockImplementation(
async (
_namespace: string,
Expand Down Expand Up @@ -549,6 +557,23 @@ describe('AgentSessionService', () => {
expect(session.status).toBe('active');
});

it('does not block session readiness on default thread creation', async () => {
const defaultThread = createDeferred<{ uuid: string }>();
mockGetDefaultThreadForSession.mockImplementationOnce(() => defaultThread.promise);

const sessionPromise = AgentSessionService.createSession(baseOpts);
const result = await Promise.race([
sessionPromise.then(() => 'resolved'),
new Promise((resolve) => setTimeout(() => resolve('timeout'), 20)),
]);

expect(result).toBe('resolved');
expect(mockGetDefaultThreadForSession).toHaveBeenCalledWith('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', 'user-123');

defaultThread.resolve({ uuid: 'default-thread-1' });
await sessionPromise;
});

it('reuses a compatible ready prewarm PVC and skips workspace bootstrap', async () => {
mockGetCompatibleReadyPrewarm.mockResolvedValue({
uuid: 'prewarm-1',
Expand Down
Loading
Loading