Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/apps/job-launcher/client/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export enum CvatJobType {
IMAGE_BOXES = 'image_boxes',
IMAGE_BOXES_FROM_POINTS = 'image_boxes_from_points',
IMAGE_SKELETONS_FROM_BOXES = 'image_skeletons_from_boxes',
AUDIO_TRANSCRIPTION = 'audio_transcription',
}

export type FortuneRequest = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const CVAT_JOB_TYPES = [
CvatJobType.IMAGE_POINTS,
CvatJobType.IMAGE_BOXES_FROM_POINTS,
CvatJobType.IMAGE_SKELETONS_FROM_BOXES,
CvatJobType.AUDIO_TRANSCRIPTION,
];

export const CANCEL_JOB_STATUSES = [
Expand Down
18 changes: 10 additions & 8 deletions packages/apps/job-launcher/server/src/common/enums/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export enum CvatJobType {
IMAGE_BOXES = 'image_boxes',
IMAGE_BOXES_FROM_POINTS = 'image_boxes_from_points',
IMAGE_SKELETONS_FROM_BOXES = 'image_skeletons_from_boxes',
AUDIO_TRANSCRIPTION = 'audio_transcription',
}

/** @deprecated Audino jobs are no longer supported. */
Expand All @@ -45,12 +46,14 @@ export enum AudinoJobType {
AUDIO_ATTRIBUTE_ANNOTATION = 'audio_attribute_annotation',
}

export const JobType = [
...Object.values(CvatJobType),
...Object.values(FortuneJobType),
...Object.values(HCaptchaJobType),
...Object.values(AudinoJobType),
];
export const JobType = Array.from(
new Set([
...Object.values(CvatJobType),
...Object.values(FortuneJobType),
...Object.values(HCaptchaJobType),
...Object.values(AudinoJobType),
]),
);

export type JobRequestType = CvatJobType | FortuneJobType | HCaptchaJobType;

Expand All @@ -59,10 +62,9 @@ export enum JobCaptchaMode {
}

export enum JobCaptchaRequestType {
IMAGE_LABEL_BINARY = 'image_label_binary',
IMAGE_LABEL_MULTIPLE_CHOICE = 'image_label_multiple_choice',
IMAGE_LABEL_AREA_SELECT = 'image_label_area_select',
TEXT_FREEE_NTRY = 'text_free_entry',
TEXT_FREE_ENTRY = 'text_free_entry',
}

export enum JobCaptchaShapeType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function generateBucketUrl(
CvatJobType.IMAGE_POINTS,
CvatJobType.IMAGE_BOXES_FROM_POINTS,
CvatJobType.IMAGE_SKELETONS_FROM_BOXES,
CvatJobType.AUDIO_TRANSCRIPTION,
] as JobRequestType[]
).includes(jobType) &&
storageData.provider != StorageProviders.AWS &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddCvatTypes1784028614177 implements MigrationInterface {
name = 'AddCvatTypes1784028614177';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TYPE "hmt"."jobs_request_type_enum"
RENAME TO "jobs_request_type_enum_old"
`);
await queryRunner.query(`
CREATE TYPE "hmt"."jobs_request_type_enum" AS ENUM(
'image_points',
'image_polygons',
'image_boxes',
'image_boxes_from_points',
'image_skeletons_from_boxes',
'audio_transcription',
'fortune',
'hcaptcha',
'audio_attribute_annotation'
)
`);
await queryRunner.query(`
ALTER TABLE "hmt"."jobs"
ALTER COLUMN "request_type" TYPE "hmt"."jobs_request_type_enum" USING "request_type"::"text"::"hmt"."jobs_request_type_enum"
`);
await queryRunner.query(`
DROP TYPE "hmt"."jobs_request_type_enum_old"
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE "hmt"."jobs_request_type_enum_old" AS ENUM(
'image_points',
'image_polygons',
'image_boxes',
'image_boxes_from_points',
'image_skeletons_from_boxes',
'fortune',
'hcaptcha',
'audio_transcription',
'audio_attribute_annotation'
)
`);
await queryRunner.query(`
ALTER TABLE "hmt"."jobs"
ALTER COLUMN "request_type" TYPE "hmt"."jobs_request_type_enum_old" USING "request_type"::"text"::"hmt"."jobs_request_type_enum_old"
`);
await queryRunner.query(`
DROP TYPE "hmt"."jobs_request_type_enum"
`);
await queryRunner.query(`
ALTER TYPE "hmt"."jobs_request_type_enum_old"
RENAME TO "jobs_request_type_enum"
`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ import {
} from '../manifest/fixtures';
import { MutexManagerService } from '../mutex/mutex-manager.service';
import { JobController } from './job.controller';
import { JobManifestDto, JobQuickLaunchDto } from './job.dto';
import {
JobManifestDto,
JobQuickLaunchDto,
JobUnknownManifestDto,
} from './job.dto';
import { JobService } from './job.service';

describe('JobController', () => {
let jobController: JobController;

const mockJobService = {
createJob: jest.fn(),
createJobWithUnknownManifest: jest.fn(),
};

const mockMutexManagerService = {
Expand Down Expand Up @@ -316,4 +321,45 @@ describe('JobController', () => {
expect(mockJobService.createJob).not.toHaveBeenCalled();
});
});

describe('createJobWithUnknownManifest', () => {
it('should create a job with an unknown manifest and return job ID', async () => {
const jobUnknownManifestDto: JobUnknownManifestDto = {
chainId: ChainId.POLYGON_AMOY,
requestType: FortuneJobType.FORTUNE,
manifest: {
custom: faker.string.sample(),
nested: {
value: faker.number.int(),
},
},
paymentCurrency: PaymentCurrency.USD,
paymentAmount: faker.number.int({ min: 100, max: 1000 }),
escrowFundToken: EscrowFundToken.HMT,
};

mockMutexManagerService.runExclusive.mockImplementationOnce(
async (_id, _timeout, fn) => {
return await fn();
},
);
mockJobService.createJobWithUnknownManifest.mockResolvedValueOnce(1);

const result = await jobController.createJobWithUnknownManifest(
jobUnknownManifestDto,
mockRequest,
);

expect(result).toBe(1);
expect(mockJobService.createJobWithUnknownManifest).toHaveBeenCalledWith(
mockRequest.user,
jobUnknownManifestDto,
);
expect(mockMutexManagerService.runExclusive).toHaveBeenCalledWith(
`user${mockRequest.user.id}`,
MUTEX_TIMEOUT,
expect.any(Function),
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
JobListDto,
JobManifestDto,
JobQuickLaunchDto,
JobUnknownManifestDto,
} from './job.dto';
import { JobService } from './job.service';

Expand Down Expand Up @@ -128,6 +129,42 @@ export class JobController {
);
}

@ApiOperation({
summary: 'Create a job with an unknown manifest',
description:
'Endpoint to create a job using a manifest JSON body without validating its format.',
})
@ApiBody({ type: JobUnknownManifestDto })
@ApiResponse({
status: 201,
description: 'ID of the created job.',
type: Number,
})
@ApiResponse({
status: 400,
description: 'Bad Request. Invalid input parameters.',
})
@ApiResponse({
status: 401,
description: 'Unauthorized. Missing or invalid credentials.',
})
@Post('/manifest-quick-launch')
public async createJobWithUnknownManifest(
@Body() data: JobUnknownManifestDto,
@Request() req: RequestWithUser,
): Promise<number> {
return await this.mutexManagerService.runExclusive(
`user${req.user.id}`,
MUTEX_TIMEOUT,
async () => {
return await this.jobService.createJobWithUnknownManifest(
req.user,
data,
);
},
);
}

@ApiOperation({
summary: 'Get a list of jobs',
description:
Expand Down
7 changes: 7 additions & 0 deletions packages/apps/job-launcher/server/src/modules/job/job.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ export class JobManifestDto extends JobDto {
public manifest: ManifestDto;
}

export class JobUnknownManifestDto extends JobDto {
@ApiProperty({ type: Object })
@IsObject()
@IsNotEmpty()
public manifest: Record<string, unknown>;
}

export class StorageDataDto {
@ApiProperty({ enum: StorageProviders })
@IsEnumCaseInsensitive(StorageProviders)
Expand Down
117 changes: 117 additions & 0 deletions packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
GetJobsDto,
JobManifestDto,
JobQuickLaunchDto,
JobUnknownManifestDto,
} from './job.dto';
import { JobRepository } from './job.repository';
import { JobService } from './job.service';
Expand Down Expand Up @@ -137,6 +138,122 @@ describe('JobService', () => {

afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});

describe('createJobWithUnknownManifest', () => {
it('should upload an unknown manifest and create a job without format validation', async () => {
const userMock = createUser({ whitelist: new WhitelistEntity() });
const dto: JobUnknownManifestDto = {
chainId: ChainId.POLYGON_AMOY,
requestType: FortuneJobType.FORTUNE,
manifest: {
arbitrary: faker.string.sample(),
schema: {
value: faker.number.int(),
},
},
reputationOracle: faker.finance.ethereumAddress(),
exchangeOracle: faker.finance.ethereumAddress(),
recordingOracle: faker.finance.ethereumAddress(),
paymentCurrency: PaymentCurrency.USD,
paymentAmount: faker.number.int({ min: 100, max: 1000 }),
escrowFundToken: EscrowFundToken.HMT,
};
const uploadedFile = {
url: faker.internet.url(),
hash: faker.string.uuid(),
};
const jobId = faker.number.int({ min: 1 });
const createJobSpy = jest
.spyOn(jobService, 'createJob')
.mockResolvedValueOnce(jobId);

mockManifestService.uploadManifest.mockResolvedValueOnce(uploadedFile);

const result = await jobService.createJobWithUnknownManifest(
userMock,
dto,
);

expect(result).toBe(jobId);
expect(mockWeb3Service.validateChainId).toHaveBeenCalledWith(dto.chainId);
expect(mockManifestService.validateManifest).not.toHaveBeenCalled();
expect(mockManifestService.uploadManifest).toHaveBeenCalledWith(
dto.chainId,
dto.manifest,
[dto.exchangeOracle, dto.reputationOracle, dto.recordingOracle],
);
expect(createJobSpy).toHaveBeenCalledWith(
userMock,
dto.requestType,
expect.objectContaining({
chainId: dto.chainId,
requestType: dto.requestType,
reputationOracle: dto.reputationOracle,
exchangeOracle: dto.exchangeOracle,
recordingOracle: dto.recordingOracle,
paymentCurrency: dto.paymentCurrency,
paymentAmount: dto.paymentAmount,
escrowFundToken: dto.escrowFundToken,
manifestUrl: uploadedFile.url,
manifestHash: uploadedFile.hash,
}),
);
expect(createJobSpy.mock.calls[0][2]).not.toHaveProperty('manifest');
});

it('should use default oracles when creating a job with an unknown manifest', async () => {
const userMock = createUser({ whitelist: new WhitelistEntity() });
const dto: JobUnknownManifestDto = {
chainId: ChainId.POLYGON_AMOY,
requestType: HCaptchaJobType.HCAPTCHA,
manifest: {
custom: faker.string.sample(),
},
paymentCurrency: PaymentCurrency.USD,
paymentAmount: faker.number.int({ min: 100, max: 1000 }),
escrowFundToken: EscrowFundToken.HMT,
};
const uploadedFile = {
url: faker.internet.url(),
hash: faker.string.uuid(),
};
const createJobSpy = jest
.spyOn(jobService, 'createJob')
.mockResolvedValueOnce(faker.number.int({ min: 1 }));

mockManifestService.uploadManifest.mockResolvedValueOnce(uploadedFile);

await jobService.createJobWithUnknownManifest(userMock, dto);

expect(mockManifestService.uploadManifest).toHaveBeenCalledWith(
dto.chainId,
dto.manifest,
[
mockWeb3ConfigService.hCaptchaOracleAddress,
mockWeb3ConfigService.hCaptchaOracleAddress,
mockWeb3ConfigService.hCaptchaOracleAddress,
],
);
expect(createJobSpy).toHaveBeenCalledWith(
userMock,
dto.requestType,
expect.objectContaining({
chainId: dto.chainId,
requestType: dto.requestType,
reputationOracle: mockWeb3ConfigService.hCaptchaOracleAddress,
exchangeOracle: mockWeb3ConfigService.hCaptchaOracleAddress,
recordingOracle: mockWeb3ConfigService.hCaptchaOracleAddress,
paymentCurrency: dto.paymentCurrency,
paymentAmount: dto.paymentAmount,
escrowFundToken: dto.escrowFundToken,
manifestUrl: uploadedFile.url,
manifestHash: uploadedFile.hash,
}),
);
expect(createJobSpy.mock.calls[0][2]).not.toHaveProperty('manifest');
});
});

describe('createJob', () => {
Expand Down
Loading
Loading