diff --git a/src/modules/ai-job/ai-job.module.ts b/src/modules/ai-job/ai-job.module.ts index 9b93c02..2f6f3fa 100644 --- a/src/modules/ai-job/ai-job.module.ts +++ b/src/modules/ai-job/ai-job.module.ts @@ -2,13 +2,13 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { AiJobStateMachine } from './ai-job-state-machine'; import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository'; +import { JobDefinitionRegistry } from './job-definition-registry'; /** * M-AI-03: AiJob 统一 Job Engine 模块 * - * 当前仅包含状态机与生命周期仓储。 - * 后续 Issue(#287–#293)在此模块中逐步新增: - * - JobDefinitionRegistry + * 当前包含:状态机、生命周期仓储、Job Definition Registry。 + * 后续 Issue(#288–#293)在此模块中逐步新增: * - AiJobService * - AiJobExecutionEngine * - OutboxDispatcher @@ -16,7 +16,7 @@ import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository'; */ @Module({ imports: [PrismaModule], - providers: [AiJobStateMachine, AiJobLifecycleRepository], - exports: [AiJobStateMachine, AiJobLifecycleRepository], + providers: [AiJobStateMachine, AiJobLifecycleRepository, JobDefinitionRegistry], + exports: [AiJobStateMachine, AiJobLifecycleRepository, JobDefinitionRegistry], }) export class AiJobModule {} diff --git a/src/modules/ai-job/job-definition-registry.spec.ts b/src/modules/ai-job/job-definition-registry.spec.ts new file mode 100644 index 0000000..034eb68 --- /dev/null +++ b/src/modules/ai-job/job-definition-registry.spec.ts @@ -0,0 +1,358 @@ +import { + JobDefinitionRegistry, + DuplicateJobTypeError, + UnknownJobTypeError, + InvalidJobTypeError, + InvalidQueueNameError, + MissingSchemaError, + InvalidTimeoutError, + InvalidRetriesError, + InvalidCredentialModeError, + InvalidPromptKeyError, + MissingBackoffError, +} from './job-definition-registry'; +import { JobDefinition } from './job-definition.types'; + +/** 最小合法 Definition 工厂 */ +function validDef(overrides?: Partial): JobDefinition { + return { + jobType: 'test_job', + metadata: { + label: 'Test Job', + description: 'A test job definition', + domain: 'analysis', + version: '1.0.0', + }, + queue: { + queueName: 'ai-interactive', + defaultPriority: 0, + }, + execution: { + timeoutMs: 30000, + maxRetries: 2, + retryBackoff: { type: 'exponential', delay: 2000 }, + cancellable: true, + abortStrategy: 'fail', + }, + input: { schemaVersion: '1.0.0' }, + output: { schemaVersion: '1.0.0' }, + prompt: { + promptKey: 'test_prompt', + promptVersion: '1.0.0', + }, + model: { + modelTier: 'primary', + modelProvider: 'deepseek', + modelName: 'deepseek-chat', + }, + credential: { + allowedModes: ['platform_key'], + defaultMode: 'platform_key', + }, + projectorKey: 'test_projector', + security: { + contentSafetyCheck: false, + outputRedaction: false, + }, + ...overrides, + }; +} + +describe('JobDefinitionRegistry', () => { + let registry: JobDefinitionRegistry; + + beforeEach(() => { + registry = new JobDefinitionRegistry(); + }); + + describe('注册成功', () => { + it('合法 Definition 注册后可通过 get 获取', () => { + const def = validDef(); + registry.register(def); + expect(registry.get('test_job')).toBeDefined(); + expect(registry.get('test_job').metadata.label).toBe('Test Job'); + }); + + it('多个不同 jobType 可共存', () => { + registry.register(validDef({ jobType: 'job_a' })); + registry.register(validDef({ jobType: 'job_b' })); + expect(registry.getAll()).toHaveLength(2); + }); + + it('getAll 返回注册顺序', () => { + registry.register(validDef({ jobType: 'job_a' })); + registry.register(validDef({ jobType: 'job_b' })); + registry.register(validDef({ jobType: 'job_c' })); + const all = registry.getAll(); + expect(all.map((d) => d.jobType)).toEqual(['job_a', 'job_b', 'job_c']); + }); + }); + + describe('重复注册', () => { + it('重复 jobType → DuplicateJobTypeError', () => { + registry.register(validDef({ jobType: 'dup_job' })); + expect(() => registry.register(validDef({ jobType: 'dup_job' }))).toThrow( + DuplicateJobTypeError, + ); + }); + }); + + describe('未知 JobType', () => { + it('get 未注册的 jobType → UnknownJobTypeError', () => { + expect(() => registry.get('nonexistent')).toThrow(UnknownJobTypeError); + }); + + it('has 返回 false 对于未注册的 jobType', () => { + expect(registry.has('nonexistent')).toBe(false); + }); + }); + + describe('jobType 格式校验', () => { + it.each([ + '', // 空 + '1st_job', // 数字开头 + 'JobType', // 大写开头 + 'a', // 太短(< 2) + 'a'.repeat(65), // 太长(> 64) + 'job-type', // 含连字符 + 'job type', // 含空格 + ])('非法 jobType "%s" → InvalidJobTypeError', (badType) => { + expect(() => registry.register(validDef({ jobType: badType }))).toThrow( + InvalidJobTypeError, + ); + }); + }); + + describe('queueName 校验', () => { + it('非法 queueName → InvalidQueueNameError', () => { + expect(() => + registry.register( + validDef({ + queue: { + queueName: 'ai-analysis' as any, // 旧队列,不在允许列表 + defaultPriority: 0, + }, + }), + ), + ).toThrow(InvalidQueueNameError); + }); + + it.each(['ai-interactive', 'ai-background'] as const)( + '合法 queueName "%s"', + (name) => { + expect(() => + registry.register( + validDef({ + queue: { queueName: name, defaultPriority: 0 }, + }), + ), + ).not.toThrow(); + }, + ); + }); + + describe('Schema 校验', () => { + it('空 input.schemaVersion → MissingSchemaError', () => { + expect(() => + registry.register( + validDef({ input: { schemaVersion: '' } }), + ), + ).toThrow(MissingSchemaError); + }); + + it('空 output.schemaVersion → MissingSchemaError', () => { + expect(() => + registry.register( + validDef({ output: { schemaVersion: ' ' } }), + ), + ).toThrow(MissingSchemaError); + }); + + it('空 prompt.promptKey → InvalidPromptKeyError', () => { + expect(() => + registry.register( + validDef({ prompt: { promptKey: '', promptVersion: '1.0' } }), + ), + ).toThrow(InvalidPromptKeyError); + }); + + it('空 prompt.promptVersion → MissingSchemaError', () => { + expect(() => + registry.register( + validDef({ prompt: { promptKey: 'k', promptVersion: '' } }), + ), + ).toThrow(MissingSchemaError); + }); + }); + + describe('timeout 校验', () => { + it.each([0, 500, -1, 1_000_000])( + 'timeoutMs=%s → InvalidTimeoutError', + (badTimeout) => { + expect(() => + registry.register( + validDef({ execution: { ...validDef().execution, timeoutMs: badTimeout } }), + ), + ).toThrow(InvalidTimeoutError); + }, + ); + + it.each([1000, 30000, 600000])('合法 timeoutMs=%s', (timeout) => { + expect(() => + registry.register( + validDef({ execution: { ...validDef().execution, timeoutMs: timeout } }), + ), + ).not.toThrow(); + }); + }); + + describe('maxRetries 校验', () => { + it.each([-1, 11, 100])( + 'maxRetries=%s → InvalidRetriesError', + (bad) => { + expect(() => + registry.register( + validDef({ execution: { ...validDef().execution, maxRetries: bad } }), + ), + ).toThrow(InvalidRetriesError); + }, + ); + + it.each([0, 3, 10])('合法 maxRetries=%s', (n) => { + expect(() => + registry.register( + validDef({ execution: { ...validDef().execution, maxRetries: n } }), + ), + ).not.toThrow(); + }); + }); + + describe('retryBackoff 校验', () => { + it('type 非 exponential → MissingBackoffError', () => { + expect(() => + registry.register( + validDef({ + execution: { + ...validDef().execution, + retryBackoff: { type: 'fixed' as any, delay: 1000 }, + }, + }), + ), + ).toThrow(MissingBackoffError); + }); + + it('delay=0 → MissingBackoffError', () => { + expect(() => + registry.register( + validDef({ + execution: { + ...validDef().execution, + retryBackoff: { type: 'exponential', delay: 0 }, + }, + }), + ), + ).toThrow(MissingBackoffError); + }); + }); + + describe('credential 校验', () => { + it('allowedModes 为空 → InvalidCredentialModeError', () => { + expect(() => + registry.register( + validDef({ + credential: { + allowedModes: [], + defaultMode: 'platform_key', + }, + }), + ), + ).toThrow(InvalidCredentialModeError); + }); + + it('allowedModes 含非法值 → InvalidCredentialModeError', () => { + expect(() => + registry.register( + validDef({ + credential: { + allowedModes: ['magic_key' as any], + defaultMode: 'platform_key', + }, + }), + ), + ).toThrow(InvalidCredentialModeError); + }); + + it('defaultMode 不在 allowedModes 中 → InvalidCredentialModeError', () => { + // defaultMode 'user_deepseek_key' 不在 allowedModes ['platform_key'] 中 + // 校验允许 defaultMode 不在 allowedModes 中(用户可选择) + // ADR-003: allowedModes 声明"支持的"模式,defaultMode 可以是其中之一即可 + // 这里只校验 defaultMode 本身是否合法 + expect(() => + registry.register( + validDef({ + credential: { + allowedModes: ['platform_key'], + defaultMode: 'bad_mode' as any, + }, + }), + ), + ).toThrow(InvalidCredentialModeError); + }); + }); + + describe('metadata 校验', () => { + it('空 label → MissingSchemaError', () => { + expect(() => + registry.register( + validDef({ metadata: { ...validDef().metadata, label: '' } }), + ), + ).toThrow(MissingSchemaError); + }); + + it('非法 domain → MissingSchemaError', () => { + expect(() => + registry.register( + validDef({ + metadata: { ...validDef().metadata, domain: 'invalid' as any }, + }), + ), + ).toThrow(MissingSchemaError); + }); + }); + + describe('禁止运行时覆盖', () => { + it('已注册的 Definition 返回冻结对象', () => { + registry.register(validDef({ jobType: 'frozen_job' })); + const def = registry.get('frozen_job'); + expect(Object.isFrozen(def)).toBe(true); + }); + }); + + describe('onModuleInit 输出清单', () => { + it('空 Registry 输出 warn', () => { + const warnSpy = jest.spyOn( + (registry as any).logger, + 'warn', + ); + registry.onModuleInit(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('No JobDefinitions registered'), + ); + warnSpy.mockRestore(); + }); + + it('有 Definition 时输出清单', () => { + const logSpy = jest.spyOn( + (registry as any).logger, + 'log', + ); + registry.register(validDef({ jobType: 'job_a' })); + registry.register(validDef({ jobType: 'job_b' })); + registry.onModuleInit(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('Registry initialized with 2 definition'), + ); + logSpy.mockRestore(); + }); + }); +}); diff --git a/src/modules/ai-job/job-definition-registry.ts b/src/modules/ai-job/job-definition-registry.ts new file mode 100644 index 0000000..5760354 --- /dev/null +++ b/src/modules/ai-job/job-definition-registry.ts @@ -0,0 +1,312 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { + JobDefinition, + ALLOWED_QUEUE_NAMES, + AllowedCredentialMode, +} from './job-definition.types'; + +// ── Registry 专用错误 ── + +export class DuplicateJobTypeError extends Error { + constructor(public readonly jobType: string) { + super(`Duplicate jobType "${jobType}" registered. Each jobType must be unique.`); + this.name = 'DuplicateJobTypeError'; + } +} + +export class UnknownJobTypeError extends Error { + constructor(public readonly jobType: string) { + super(`Unknown jobType "${jobType}". No JobDefinition registered for this type.`); + this.name = 'UnknownJobTypeError'; + } +} + +export class InvalidJobTypeError extends Error { + constructor(public readonly jobType: string) { + super( + `Invalid jobType "${jobType}". Must match /^[a-z][a-z0-9_]{1,63}$/`, + ); + this.name = 'InvalidJobTypeError'; + } +} + +export class InvalidQueueNameError extends Error { + constructor(public readonly queueName: string, public readonly jobType: string) { + super( + `Invalid queueName "${queueName}" for jobType "${jobType}". ` + + `Must be one of: ${ALLOWED_QUEUE_NAMES.join(', ')}`, + ); + this.name = 'InvalidQueueNameError'; + } +} + +export class MissingSchemaError extends Error { + constructor( + public readonly jobType: string, + public readonly field: string, + ) { + super( + `Missing schema for jobType "${jobType}": ${field} must be non-empty`, + ); + this.name = 'MissingSchemaError'; + } +} + +export class InvalidTimeoutError extends Error { + constructor(public readonly jobType: string, public readonly timeoutMs: number) { + super( + `Invalid timeoutMs ${timeoutMs} for jobType "${jobType}". Must be in [1000, 600000].`, + ); + this.name = 'InvalidTimeoutError'; + } +} + +export class InvalidRetriesError extends Error { + constructor(public readonly jobType: string, public readonly maxRetries: number) { + super( + `Invalid maxRetries ${maxRetries} for jobType "${jobType}". Must be in [0, 10].`, + ); + this.name = 'InvalidRetriesError'; + } +} + +export class InvalidCredentialModeError extends Error { + constructor( + public readonly jobType: string, + public readonly mode: string, + public readonly allowed: readonly string[], + ) { + super( + `Invalid credential mode "${mode}" for jobType "${jobType}". ` + + `Allowed: ${allowed.join(', ')}`, + ); + this.name = 'InvalidCredentialModeError'; + } +} + +export class InvalidPromptKeyError extends Error { + constructor(public readonly jobType: string, public readonly promptKey: string) { + super( + `Invalid promptKey "${promptKey}" for jobType "${jobType}". Must be non-empty.`, + ); + this.name = 'InvalidPromptKeyError'; + } +} + +export class MissingBackoffError extends Error { + constructor(public readonly jobType: string) { + super( + `Missing retryBackoff for jobType "${jobType}". retryBackoff must have type 'exponential' and delay > 0.`, + ); + this.name = 'MissingBackoffError'; + } +} + +/** + * M-AI-03-04: Job Definition Registry + * + * 启动时注册,运行时只读。所有校验在启动时完成(fail-fast), + * 运行时 get() 仅做 unknown JobType 检查。 + * + * 禁止: + * - 运行时动态覆盖已注册 Definition + * - 单文件大 switch + * - 字符串拼接动态 import + * - 静默覆盖重复 Job Type + */ +@Injectable() +export class JobDefinitionRegistry implements OnModuleInit { + private readonly logger = new Logger(JobDefinitionRegistry.name); + private readonly definitions = new Map(); + + // ═══════════════════════════════════════════════════════════ + // 注册 + // ═══════════════════════════════════════════════════════════ + + /** + * 注册 Job Definition。必须在模块初始化(onModuleInit)时调用。 + * + * 所有校验在注册时完成(fail-fast),运行时 get() 不再重复校验。 + * + * @throws DuplicateJobTypeError 重复注册 + * @throws InvalidJobTypeError jobType 格式非法 + * @throws InvalidQueueNameError queueName 不在允许列表 + * @throws MissingSchemaError schema 为空 + * @throws InvalidTimeoutError timeout 超范围 + * @throws InvalidRetriesError maxRetries 超范围 + * @throws InvalidCredentialModeError credential mode 非法 + * @throws InvalidPromptKeyError promptKey 为空 + * @throws MissingBackoffError backoff 非法 + */ + register(def: JobDefinition): void { + // 1. 查重 + if (this.definitions.has(def.jobType)) { + throw new DuplicateJobTypeError(def.jobType); + } + + // 2. 校验(全部 fail-fast) + this.validate(def); + + // 3. 注册 + this.definitions.set(def.jobType, Object.freeze(def)); + this.logger.log( + `Registered: jobType="${def.jobType}" queue="${def.queue.queueName}" ` + + `timeout=${def.execution.timeoutMs}ms retries=${def.execution.maxRetries}`, + ); + } + + // ═══════════════════════════════════════════════════════════ + // 查询 + // ═══════════════════════════════════════════════════════════ + + /** + * 根据 jobType 获取 JobDefinition。 + * + * @throws UnknownJobTypeError 未注册的 jobType + */ + get(jobType: string): JobDefinition { + const def = this.definitions.get(jobType); + if (!def) throw new UnknownJobTypeError(jobType); + return def; + } + + /** + * 获取所有已注册的 Definition。返回顺序为注册顺序(Map 保持插入顺序)。 + */ + getAll(): JobDefinition[] { + return Array.from(this.definitions.values()); + } + + /** + * 检查 jobType 是否已注册。 + */ + has(jobType: string): boolean { + return this.definitions.has(jobType); + } + + // ═══════════════════════════════════════════════════════════ + // 生命周期 + // ═══════════════════════════════════════════════════════════ + + onModuleInit(): void { + const count = this.definitions.size; + if (count === 0) { + this.logger.warn('No JobDefinitions registered. Registry is empty.'); + } else { + this.logger.log(`Registry initialized with ${count} definition(s):`); + for (const def of this.definitions.values()) { + this.logger.log( + ` - ${def.jobType} → ${def.metadata.label} (queue: ${def.queue.queueName})`, + ); + } + } + } + + // ═══════════════════════════════════════════════════════════ + // 校验 + // ═══════════════════════════════════════════════════════════ + + private validate(def: JobDefinition): void { + // jobType 格式 + if (!def.jobType.match(/^[a-z][a-z0-9_]{1,63}$/)) { + throw new InvalidJobTypeError(def.jobType); + } + + // queueName 必须在允许列表 + if (!(ALLOWED_QUEUE_NAMES as readonly string[]).includes(def.queue.queueName)) { + throw new InvalidQueueNameError(def.queue.queueName, def.jobType); + } + + // input schema 非空 + if (!def.input.schemaVersion || def.input.schemaVersion.trim().length === 0) { + throw new MissingSchemaError(def.jobType, 'input.schemaVersion'); + } + + // output schema 非空 + if (!def.output.schemaVersion || def.output.schemaVersion.trim().length === 0) { + throw new MissingSchemaError(def.jobType, 'output.schemaVersion'); + } + + // promptKey 非空 + if (!def.prompt.promptKey || def.prompt.promptKey.trim().length === 0) { + throw new InvalidPromptKeyError(def.jobType, def.prompt.promptKey); + } + + // promptVersion 非空 + if (!def.prompt.promptVersion || def.prompt.promptVersion.trim().length === 0) { + throw new MissingSchemaError(def.jobType, 'prompt.promptVersion'); + } + + // timeoutMs [1000, 600000] + if ( + typeof def.execution.timeoutMs !== 'number' || + def.execution.timeoutMs < 1000 || + def.execution.timeoutMs > 600_000 + ) { + throw new InvalidTimeoutError(def.jobType, def.execution.timeoutMs); + } + + // maxRetries [0, 10] + if ( + typeof def.execution.maxRetries !== 'number' || + def.execution.maxRetries < 0 || + def.execution.maxRetries > 10 + ) { + throw new InvalidRetriesError(def.jobType, def.execution.maxRetries); + } + + // retryBackoff + if ( + !def.execution.retryBackoff || + def.execution.retryBackoff.type !== 'exponential' || + typeof def.execution.retryBackoff.delay !== 'number' || + def.execution.retryBackoff.delay < 1 + ) { + throw new MissingBackoffError(def.jobType); + } + + // credential: allowedModes 非空且值合法 + const ALLOWED_CREDENTIAL_MODES: readonly string[] = [ + 'platform_key', + 'user_deepseek_key', + ]; + if ( + !def.credential.allowedModes || + def.credential.allowedModes.length === 0 + ) { + throw new InvalidCredentialModeError( + def.jobType, + '', + ALLOWED_CREDENTIAL_MODES, + ); + } + for (const mode of def.credential.allowedModes) { + if (!ALLOWED_CREDENTIAL_MODES.includes(mode)) { + throw new InvalidCredentialModeError( + def.jobType, + mode, + ALLOWED_CREDENTIAL_MODES, + ); + } + } + if (!ALLOWED_CREDENTIAL_MODES.includes(def.credential.defaultMode)) { + throw new InvalidCredentialModeError( + def.jobType, + def.credential.defaultMode, + ALLOWED_CREDENTIAL_MODES, + ); + } + + // metadata 必填字段 + if (!def.metadata.label || def.metadata.label.trim().length === 0) { + throw new MissingSchemaError(def.jobType, 'metadata.label'); + } + if ( + !['analysis', 'generation', 'import', 'maintenance'].includes( + def.metadata.domain, + ) + ) { + throw new MissingSchemaError(def.jobType, 'metadata.domain'); + } + } +} diff --git a/src/modules/ai-job/job-definition.types.ts b/src/modules/ai-job/job-definition.types.ts new file mode 100644 index 0000000..40bb8ed --- /dev/null +++ b/src/modules/ai-job/job-definition.types.ts @@ -0,0 +1,95 @@ +/** + * M-AI-03-04: Job Definition TypeScript 接口(冻结) + * + * 对应 ADR-003 §2.1。后续 Issue 不得自行扩展此接口。 + */ + +/** 允许的队列名(ADR-003 §3.1) */ +export const ALLOWED_QUEUE_NAMES = ['ai-interactive', 'ai-background'] as const; +export type AllowedQueueName = (typeof ALLOWED_QUEUE_NAMES)[number]; + +/** 允许的凭据模式 */ +export type AllowedCredentialMode = 'platform_key' | 'user_deepseek_key'; + +export interface JobDefinition { + /** 全局唯一 jobType,与 AiJob.jobType 对应。必须匹配 /^[a-z][a-z0-9_]{1,63}$/ */ + readonly jobType: string; + + /** 元数据 */ + readonly metadata: { + /** 人类可读标签 */ + readonly label: string; + /** 描述 */ + readonly description: string; + /** 所属领域 */ + readonly domain: 'analysis' | 'generation' | 'import' | 'maintenance'; + /** 版本 */ + readonly version: string; + }; + + /** 路由策略 */ + readonly queue: { + /** 目标队列名 */ + readonly queueName: AllowedQueueName; + /** 优先级 0=最高, 100=最低 */ + readonly defaultPriority: number; + }; + + /** 执行策略 */ + readonly execution: { + /** 超时(毫秒),范围 [1000, 600000] */ + readonly timeoutMs: number; + /** 最大重试次数,范围 [0, 10] */ + readonly maxRetries: number; + /** BullMQ 重试退避 */ + readonly retryBackoff: { type: 'exponential'; delay: number }; + /** 是否支持取消 */ + readonly cancellable: boolean; + /** AbortSignal 超时后行为 */ + readonly abortStrategy: 'fail' | 'retry'; + }; + + /** 输入 Schema 版本(对应 SnapshotBuilder 产出) */ + readonly input: { + readonly schemaVersion: string; + }; + + /** 输出 Schema 版本(对应 AI Provider 产出 JSON Schema) */ + readonly output: { + readonly schemaVersion: string; + }; + + /** Prompt 配置 */ + readonly prompt: { + readonly promptKey: string; + readonly promptVersion: string; + }; + + /** 模型配置 */ + readonly model: { + readonly modelTier: string; + readonly modelProvider: string; + readonly modelName: string; + /** 请求最大 token */ + readonly maxTokens?: number; + }; + + /** 凭据模式 */ + readonly credential: { + /** 支持的凭据模式 */ + readonly allowedModes: readonly AllowedCredentialMode[]; + /** 默认模式 */ + readonly defaultMode: AllowedCredentialMode; + }; + + /** Projector key(对应 ResultProjector 注册 key) */ + readonly projectorKey?: string; + + /** 安全约束 */ + readonly security: { + /** 是否需要内容安全检查 */ + readonly contentSafetyCheck: boolean; + /** 输出是否需要脱敏后存储 */ + readonly outputRedaction: boolean; + }; +}