diff --git a/packages/apps/job-launcher/client/src/types/index.ts b/packages/apps/job-launcher/client/src/types/index.ts index 9e240856be..0f59474dab 100644 --- a/packages/apps/job-launcher/client/src/types/index.ts +++ b/packages/apps/job-launcher/client/src/types/index.ts @@ -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 = { diff --git a/packages/apps/job-launcher/server/src/common/constants/index.ts b/packages/apps/job-launcher/server/src/common/constants/index.ts index 7bbb8b9a5e..aa4b361175 100644 --- a/packages/apps/job-launcher/server/src/common/constants/index.ts +++ b/packages/apps/job-launcher/server/src/common/constants/index.ts @@ -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 = [ diff --git a/packages/apps/job-launcher/server/src/common/enums/job.ts b/packages/apps/job-launcher/server/src/common/enums/job.ts index db16799d11..bc8119a981 100644 --- a/packages/apps/job-launcher/server/src/common/enums/job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/job.ts @@ -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. */ @@ -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; @@ -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 { diff --git a/packages/apps/job-launcher/server/src/common/utils/storage.ts b/packages/apps/job-launcher/server/src/common/utils/storage.ts index fbe86382ec..b5e829b30b 100644 --- a/packages/apps/job-launcher/server/src/common/utils/storage.ts +++ b/packages/apps/job-launcher/server/src/common/utils/storage.ts @@ -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 && diff --git a/packages/apps/job-launcher/server/src/database/migrations/1784028614177-addCvatTypes.ts b/packages/apps/job-launcher/server/src/database/migrations/1784028614177-addCvatTypes.ts new file mode 100644 index 0000000000..5558b6bf54 --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1784028614177-addCvatTypes.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCvatTypes1784028614177 implements MigrationInterface { + name = 'AddCvatTypes1784028614177'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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" + `); + } +} diff --git a/packages/apps/job-launcher/server/src/modules/job/job.controller.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.controller.spec.ts index 5778fa133d..d294f939e7 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.controller.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.controller.spec.ts @@ -23,7 +23,11 @@ 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', () => { @@ -31,6 +35,7 @@ describe('JobController', () => { const mockJobService = { createJob: jest.fn(), + createJobWithUnknownManifest: jest.fn(), }; const mockMutexManagerService = { @@ -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), + ); + }); + }); }); diff --git a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts index abb5dbdf0f..1fda2dbe6b 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.controller.ts @@ -33,6 +33,7 @@ import { JobListDto, JobManifestDto, JobQuickLaunchDto, + JobUnknownManifestDto, } from './job.dto'; import { JobService } from './job.service'; @@ -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 { + 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: diff --git a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts index 2fd27bb044..3e193930fd 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts @@ -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; +} + export class StorageDataDto { @ApiProperty({ enum: StorageProviders }) @IsEnumCaseInsensitive(StorageProviders) diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index b9bb5312a8..b347647a80 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -61,6 +61,7 @@ import { GetJobsDto, JobManifestDto, JobQuickLaunchDto, + JobUnknownManifestDto, } from './job.dto'; import { JobRepository } from './job.repository'; import { JobService } from './job.service'; @@ -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', () => { diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 2c967136b3..5e05787098 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -75,6 +75,8 @@ import { GetJobsDto, JobDetailsDto, JobListDto, + JobQuickLaunchDto, + JobUnknownManifestDto, } from './job.dto'; import { JobEntity } from './job.entity'; import { JobRepository } from './job.repository'; @@ -271,6 +273,41 @@ export class JobService { return jobEntity.id; } + public async createJobWithUnknownManifest( + user: UserEntity, + dto: JobUnknownManifestDto, + ): Promise { + const { chainId, requestType, manifest } = dto; + let { reputationOracle, exchangeOracle, recordingOracle } = dto; + + this.web3Service.validateChainId(chainId); + + if (!reputationOracle || !exchangeOracle || !recordingOracle) { + const defaultOracles = await this.getDefaultOracles(chainId, requestType); + reputationOracle = reputationOracle ?? defaultOracles.reputationOracle; + exchangeOracle = exchangeOracle ?? defaultOracles.exchangeOracle; + recordingOracle = recordingOracle ?? defaultOracles.recordingOracle; + } + + const { url, hash } = await this.manifestService.uploadManifest( + chainId, + manifest, + [exchangeOracle, reputationOracle, recordingOracle], + ); + + const { manifest: _manifest, ...jobDto } = dto; + const quickLaunchDto: JobQuickLaunchDto = { + ...jobDto, + reputationOracle, + exchangeOracle, + recordingOracle, + manifestUrl: url, + manifestHash: hash, + }; + + return this.createJob(user, dto.requestType, quickLaunchDto); + } + private async getDefaultOracles( chainId: ChainId, requestType: JobRequestType, diff --git a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts index 413e893a0a..5fa407a6d2 100644 --- a/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/manifest/manifest.service.spec.ts @@ -64,7 +64,7 @@ describe('ManifestService', () => { it('should validate an hcaptcha manifest successfully', async () => { const manifest = { job_mode: faker.lorem.word(), - request_type: JobCaptchaRequestType.IMAGE_LABEL_BINARY, + request_type: JobCaptchaRequestType.IMAGE_LABEL_AREA_SELECT, request_config: {}, requester_accuracy_target: faker.number.float({ min: 0.5, @@ -112,7 +112,7 @@ describe('ManifestService', () => { it('should throw when a required hcaptcha property is missing', async () => { const manifest = { job_mode: faker.lorem.word(), - request_type: JobCaptchaRequestType.IMAGE_LABEL_BINARY, + request_type: JobCaptchaRequestType.IMAGE_LABEL_AREA_SELECT, request_config: {}, requester_accuracy_target: faker.number.float({ min: 0.5,