From 64be92e1bba0a2ab42dfd3c2d365af93b7c67152 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 12 Mar 2026 10:17:43 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20job=20registry=20=E2=80=94=20scalab?= =?UTF-8?q?le=20job=20architecture=20(#75)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a central Job Registry that makes adding a new job type require only 2-3 file changes instead of 15+. ## Core changes - New `packages/core/src/jobs/job-registry.ts` with IJobDefinition, JOB_REGISTRY (6 jobs), and utility functions: getJobDef, getAllJobDefs, getJobDefByCommand, getJobDefByLogName, getValidJobTypes, getDefaultQueuePriority, getLogFileNames, getLockSuffix, normalizeJobConfig, buildJobEnvOverrides, camelToUpperSnake - VALID_JOB_TYPES, DEFAULT_QUEUE_PRIORITY, LOG_FILE_NAMES in constants.ts now derived from registry instead of hardcoded - config-normalize.ts: replace per-job qa/audit/analytics blocks with registry loop - config-env.ts: replace NW_QA_*/NW_AUDIT_*/NW_ANALYTICS_* blocks with registry loop ## Web changes - web/utils/jobs.ts: add IWebJobDefinition, WEB_JOB_REGISTRY (with getEnabled, getSchedule, buildEnabledPatch per job), getWebJobDef() - web/api.ts: add generic triggerJob(jobId) function - web/store/useStore.ts: add IWebJobState and getJobStates() computed getter - web/pages/Scheduling.tsx: replace handleJobToggle if/else chain and triggerMap with registry-driven code - web/store/useStore.ts: Zustand jobs computed slice ## Server changes - action.routes.ts: replace qa/audit/analytics/planner route handlers with JOB_REGISTRY loop Co-Authored-By: Claude Sonnet 4.6 --- .../src/__tests__/config-normalize.test.ts | 58 ++ .../src/__tests__/jobs/job-registry.test.ts | 451 +++++++++++++++ packages/core/src/config-env.ts | 89 +-- packages/core/src/config-normalize.ts | 73 +-- packages/core/src/constants.ts | 33 +- packages/core/src/index.ts | 1 + packages/core/src/jobs/index.ts | 17 + packages/core/src/jobs/job-registry.ts | 530 ++++++++++++++++++ packages/server/src/routes/action.routes.ts | 25 +- web/api.ts | 21 + web/pages/Scheduling.tsx | 58 +- web/store/useStore.ts | 30 +- web/utils/jobs.ts | 98 ++++ 13 files changed, 1269 insertions(+), 215 deletions(-) create mode 100644 packages/core/src/__tests__/jobs/job-registry.test.ts create mode 100644 packages/core/src/jobs/index.ts create mode 100644 packages/core/src/jobs/job-registry.ts diff --git a/packages/core/src/__tests__/config-normalize.test.ts b/packages/core/src/__tests__/config-normalize.test.ts index 9ff20c22..f90bcfec 100644 --- a/packages/core/src/__tests__/config-normalize.test.ts +++ b/packages/core/src/__tests__/config-normalize.test.ts @@ -186,6 +186,64 @@ describe('config-normalize', () => { // Both should be skipped expect(normalized.providerPresets).toBeUndefined(); }); + }); +}); + +describe('normalizeConfig - registry-driven job configs', () => { + it('normalizes qa config via registry', () => { + const normalized = normalizeConfig({ + qa: { + enabled: false, + schedule: '0 12 * * *', + maxRuntime: 1800, + artifacts: 'screenshot', + skipLabel: 'no-qa', + autoInstallPlaywright: false, + branchPatterns: ['feat/'], + }, + }); + expect(normalized.qa?.enabled).toBe(false); + expect(normalized.qa?.schedule).toBe('0 12 * * *'); + expect(normalized.qa?.maxRuntime).toBe(1800); + expect((normalized.qa as Record)?.artifacts).toBe('screenshot'); + expect((normalized.qa as Record)?.skipLabel).toBe('no-qa'); + expect((normalized.qa as Record)?.autoInstallPlaywright).toBe(false); + expect((normalized.qa as Record)?.branchPatterns).toEqual(['feat/']); + }); + + it('normalizes audit config via registry', () => { + const normalized = normalizeConfig({ + audit: { enabled: false, schedule: '0 4 * * 0', maxRuntime: 900 }, + }); + expect(normalized.audit?.enabled).toBe(false); + expect(normalized.audit?.schedule).toBe('0 4 * * 0'); + expect(normalized.audit?.maxRuntime).toBe(900); + }); + + it('normalizes analytics config via registry', () => { + const normalized = normalizeConfig({ + analytics: { + enabled: true, + schedule: '0 8 * * 1', + maxRuntime: 600, + lookbackDays: 14, + targetColumn: 'Ready', + }, + }); + expect(normalized.analytics?.enabled).toBe(true); + expect((normalized.analytics as Record)?.lookbackDays).toBe(14); + expect((normalized.analytics as Record)?.targetColumn).toBe('Ready'); + }); + + it('applies qa defaults for missing fields', () => { + const normalized = normalizeConfig({ qa: {} }); + expect(normalized.qa?.enabled).toBe(true); + expect((normalized.qa as Record)?.artifacts).toBe('both'); + expect((normalized.qa as Record)?.autoInstallPlaywright).toBe(true); + }); + it('rejects invalid qa artifacts enum value', () => { + const normalized = normalizeConfig({ qa: { artifacts: 'invalid' } }); + expect((normalized.qa as Record)?.artifacts).toBe('both'); // falls back to default }); }); diff --git a/packages/core/src/__tests__/jobs/job-registry.test.ts b/packages/core/src/__tests__/jobs/job-registry.test.ts new file mode 100644 index 00000000..7b5f3ff4 --- /dev/null +++ b/packages/core/src/__tests__/jobs/job-registry.test.ts @@ -0,0 +1,451 @@ +/** + * Tests for the Job Registry + */ + +import { afterEach, describe, it, expect } from 'vitest'; +import { + JOB_REGISTRY, + getJobDef, + getJobDefByCommand, + getJobDefByLogName, + getValidJobTypes, + getDefaultQueuePriority, + getLogFileNames, + getLockSuffix, + getAllJobDefs, + normalizeJobConfig, + buildJobEnvOverrides, + camelToUpperSnake, +} from '../../jobs/job-registry.js'; +import { VALID_JOB_TYPES, DEFAULT_QUEUE_PRIORITY, LOG_FILE_NAMES } from '../../constants.js'; + +describe('JOB_REGISTRY', () => { + it('should define all 6 job types', () => { + expect(JOB_REGISTRY).toHaveLength(6); + }); + + it('should include executor, reviewer, qa, audit, slicer, analytics', () => { + const ids = JOB_REGISTRY.map((j) => j.id); + expect(ids).toContain('executor'); + expect(ids).toContain('reviewer'); + expect(ids).toContain('qa'); + expect(ids).toContain('audit'); + expect(ids).toContain('slicer'); + expect(ids).toContain('analytics'); + }); + + it('each job definition has required fields', () => { + for (const job of JOB_REGISTRY) { + expect(typeof job.id).toBe('string'); + expect(typeof job.name).toBe('string'); + expect(typeof job.description).toBe('string'); + expect(typeof job.cliCommand).toBe('string'); + expect(typeof job.logName).toBe('string'); + expect(typeof job.lockSuffix).toBe('string'); + expect(typeof job.queuePriority).toBe('number'); + expect(typeof job.envPrefix).toBe('string'); + expect(job.defaultConfig).toBeDefined(); + expect(typeof job.defaultConfig.enabled).toBe('boolean'); + expect(typeof job.defaultConfig.schedule).toBe('string'); + expect(typeof job.defaultConfig.maxRuntime).toBe('number'); + } + }); +}); + +describe('getJobDef', () => { + it('returns correct definition for executor', () => { + const def = getJobDef('executor'); + expect(def).toBeDefined(); + expect(def!.name).toBe('Executor'); + expect(def!.cliCommand).toBe('run'); + }); + + it('returns correct definition for qa', () => { + const def = getJobDef('qa'); + expect(def).toBeDefined(); + expect(def!.name).toBe('QA'); + }); + + it('returns correct definition for slicer', () => { + const def = getJobDef('slicer'); + expect(def).toBeDefined(); + expect(def!.cliCommand).toBe('planner'); + }); + + it('returns undefined for unknown job type', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(getJobDef('unknown' as any)).toBeUndefined(); + }); +}); + +describe('getJobDefByCommand', () => { + it('finds executor by "run" command', () => { + const def = getJobDefByCommand('run'); + expect(def?.id).toBe('executor'); + }); + + it('finds slicer by "planner" command', () => { + const def = getJobDefByCommand('planner'); + expect(def?.id).toBe('slicer'); + }); + + it('returns undefined for unknown command', () => { + expect(getJobDefByCommand('unknown')).toBeUndefined(); + }); +}); + +describe('getJobDefByLogName', () => { + it('finds qa by log name "night-watch-qa"', () => { + const def = getJobDefByLogName('night-watch-qa'); + expect(def?.id).toBe('qa'); + }); + + it('finds executor by log name "executor"', () => { + const def = getJobDefByLogName('executor'); + expect(def?.id).toBe('executor'); + }); +}); + +describe('getValidJobTypes', () => { + it('returns all 6 job types', () => { + const types = getValidJobTypes(); + expect(types).toHaveLength(6); + expect(types).toContain('executor'); + expect(types).toContain('reviewer'); + expect(types).toContain('qa'); + expect(types).toContain('audit'); + expect(types).toContain('slicer'); + expect(types).toContain('analytics'); + }); +}); + +describe('getDefaultQueuePriority', () => { + it('returns priority for all job types', () => { + const priority = getDefaultQueuePriority(); + expect(typeof priority.executor).toBe('number'); + expect(typeof priority.reviewer).toBe('number'); + expect(typeof priority.qa).toBe('number'); + expect(typeof priority.audit).toBe('number'); + expect(typeof priority.slicer).toBe('number'); + expect(typeof priority.analytics).toBe('number'); + }); + + it('executor has highest priority', () => { + const priority = getDefaultQueuePriority(); + expect(priority.executor).toBeGreaterThan(priority.reviewer); + expect(priority.reviewer).toBeGreaterThan(priority.qa); + }); +}); + +describe('getLogFileNames', () => { + it('maps executor id and cliCommand to logName', () => { + const logFiles = getLogFileNames(); + expect(logFiles.executor).toBe('executor'); + }); + + it('maps slicer id to "slicer" logName', () => { + const logFiles = getLogFileNames(); + expect(logFiles.slicer).toBe('slicer'); + }); + + it('maps planner (slicer cliCommand) to "slicer" logName', () => { + const logFiles = getLogFileNames(); + expect(logFiles.planner).toBe('slicer'); + }); + + it('maps qa to "night-watch-qa"', () => { + const logFiles = getLogFileNames(); + expect(logFiles.qa).toBe('night-watch-qa'); + }); +}); + +describe('getLockSuffix', () => { + it('returns correct lock suffix for executor', () => { + expect(getLockSuffix('executor')).toBe('.lock'); + }); + + it('returns correct lock suffix for reviewer', () => { + expect(getLockSuffix('reviewer')).toBe('-r.lock'); + }); + + it('returns correct lock suffix for qa', () => { + expect(getLockSuffix('qa')).toBe('-qa.lock'); + }); +}); + +describe('getAllJobDefs', () => { + it('returns a copy of the registry array', () => { + const defs = getAllJobDefs(); + expect(defs).toHaveLength(JOB_REGISTRY.length); + // Should be a copy, not the same reference + expect(defs).not.toBe(JOB_REGISTRY); + }); +}); + +describe('migrateLegacy', () => { + it('executor migrates cronSchedule from flat format', () => { + const def = getJobDef('executor')!; + const raw = { cronSchedule: '*/5 * * * *', executorEnabled: false, maxRuntime: 3600 }; + const migrated = def.migrateLegacy?.(raw); + expect(migrated?.schedule).toBe('*/5 * * * *'); + expect(migrated?.enabled).toBe(false); + expect(migrated?.maxRuntime).toBe(3600); + }); + + it('executor returns undefined when no legacy fields present', () => { + const def = getJobDef('executor')!; + const migrated = def.migrateLegacy?.({}); + expect(migrated).toBeUndefined(); + }); + + it('reviewer migrates from reviewerSchedule/reviewerEnabled', () => { + const def = getJobDef('reviewer')!; + const raw = { reviewerSchedule: '*/10 * * * *', reviewerEnabled: false }; + const migrated = def.migrateLegacy?.(raw); + expect(migrated?.schedule).toBe('*/10 * * * *'); + expect(migrated?.enabled).toBe(false); + }); + + it('slicer migrates from roadmapScanner.slicerSchedule', () => { + const def = getJobDef('slicer')!; + const raw = { + roadmapScanner: { slicerSchedule: '0 */6 * * *', slicerMaxRuntime: 300, enabled: true }, + }; + const migrated = def.migrateLegacy?.(raw); + expect(migrated?.schedule).toBe('0 */6 * * *'); + expect(migrated?.maxRuntime).toBe(300); + expect(migrated?.enabled).toBe(true); + }); +}); + +describe('derived constants match expected values', () => { + it('VALID_JOB_TYPES from constants matches getValidJobTypes()', () => { + const fromRegistry = getValidJobTypes(); + expect(VALID_JOB_TYPES).toEqual(fromRegistry); + }); + + it('DEFAULT_QUEUE_PRIORITY from constants matches getDefaultQueuePriority()', () => { + const fromRegistry = getDefaultQueuePriority(); + expect(DEFAULT_QUEUE_PRIORITY).toEqual(fromRegistry); + }); + + it('LOG_FILE_NAMES from constants matches getLogFileNames()', () => { + const fromRegistry = getLogFileNames(); + expect(LOG_FILE_NAMES).toEqual(fromRegistry); + }); +}); + +describe('camelToUpperSnake', () => { + it('converts camelCase to UPPER_SNAKE_CASE', () => { + expect(camelToUpperSnake('lookbackDays')).toBe('LOOKBACK_DAYS'); + expect(camelToUpperSnake('branchPatterns')).toBe('BRANCH_PATTERNS'); + expect(camelToUpperSnake('autoInstallPlaywright')).toBe('AUTO_INSTALL_PLAYWRIGHT'); + expect(camelToUpperSnake('skipLabel')).toBe('SKIP_LABEL'); + expect(camelToUpperSnake('targetColumn')).toBe('TARGET_COLUMN'); + }); + + it('handles single-word names', () => { + expect(camelToUpperSnake('enabled')).toBe('ENABLED'); + expect(camelToUpperSnake('schedule')).toBe('SCHEDULE'); + expect(camelToUpperSnake('artifacts')).toBe('ARTIFACTS'); + }); +}); + +describe('normalizeJobConfig', () => { + it('normalizes qa config with all base fields', () => { + const qaDef = getJobDef('qa')!; + const result = normalizeJobConfig( + { enabled: false, schedule: '0 12 * * *', maxRuntime: 1800 }, + qaDef, + ); + expect(result.enabled).toBe(false); + expect(result.schedule).toBe('0 12 * * *'); + expect(result.maxRuntime).toBe(1800); + }); + + it('applies qa defaults for missing fields', () => { + const qaDef = getJobDef('qa')!; + const result = normalizeJobConfig({}, qaDef); + expect(result.enabled).toBe(true); + expect(result.artifacts).toBe('both'); + expect(result.skipLabel).toBe('skip-qa'); + expect(result.autoInstallPlaywright).toBe(true); + expect(result.branchPatterns).toEqual([]); + }); + + it('normalizes qa extra fields', () => { + const qaDef = getJobDef('qa')!; + const result = normalizeJobConfig( + { + enabled: true, + artifacts: 'screenshot', + skipLabel: 'no-qa', + autoInstallPlaywright: false, + branchPatterns: ['feat/', 'fix/'], + }, + qaDef, + ); + expect(result.artifacts).toBe('screenshot'); + expect(result.skipLabel).toBe('no-qa'); + expect(result.autoInstallPlaywright).toBe(false); + expect(result.branchPatterns).toEqual(['feat/', 'fix/']); + }); + + it('rejects invalid enum value and falls back to default', () => { + const qaDef = getJobDef('qa')!; + const result = normalizeJobConfig({ artifacts: 'invalid-value' }, qaDef); + expect(result.artifacts).toBe('both'); + }); + + it('normalizes audit config with no extra fields', () => { + const auditDef = getJobDef('audit')!; + const result = normalizeJobConfig( + { enabled: false, schedule: '0 4 * * 0', maxRuntime: 900 }, + auditDef, + ); + expect(result.enabled).toBe(false); + expect(result.schedule).toBe('0 4 * * 0'); + expect(result.maxRuntime).toBe(900); + }); + + it('normalizes analytics extra fields', () => { + const analyticsDef = getJobDef('analytics')!; + const result = normalizeJobConfig( + { enabled: true, lookbackDays: 14, targetColumn: 'Ready', analysisPrompt: 'test prompt' }, + analyticsDef, + ); + expect(result.lookbackDays).toBe(14); + expect(result.targetColumn).toBe('Ready'); + expect(result.analysisPrompt).toBe('test prompt'); + }); + + it('rejects invalid analytics targetColumn and falls back to default', () => { + const analyticsDef = getJobDef('analytics')!; + const result = normalizeJobConfig({ targetColumn: 'NotAColumn' }, analyticsDef); + expect(result.targetColumn).toBe('Draft'); + }); +}); + +describe('buildJobEnvOverrides', () => { + afterEach(() => { + // Clean up env vars set in tests + delete process.env.NW_QA_ENABLED; + delete process.env.NW_QA_SCHEDULE; + delete process.env.NW_QA_MAX_RUNTIME; + delete process.env.NW_QA_ARTIFACTS; + delete process.env.NW_QA_SKIP_LABEL; + delete process.env.NW_QA_AUTO_INSTALL_PLAYWRIGHT; + delete process.env.NW_QA_BRANCH_PATTERNS; + delete process.env.NW_AUDIT_ENABLED; + delete process.env.NW_AUDIT_SCHEDULE; + delete process.env.NW_AUDIT_MAX_RUNTIME; + delete process.env.NW_ANALYTICS_ENABLED; + delete process.env.NW_ANALYTICS_LOOKBACK_DAYS; + delete process.env.NW_ANALYTICS_TARGET_COLUMN; + }); + + it('returns null when no env vars are set', () => { + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).toBeNull(); + }); + + it('overrides enabled via NW_QA_ENABLED', () => { + process.env.NW_QA_ENABLED = 'false'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.enabled).toBe(false); + }); + + it('overrides schedule via NW_QA_SCHEDULE', () => { + process.env.NW_QA_SCHEDULE = '0 6 * * *'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.schedule).toBe('0 6 * * *'); + }); + + it('overrides maxRuntime via NW_QA_MAX_RUNTIME', () => { + process.env.NW_QA_MAX_RUNTIME = '1200'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.maxRuntime).toBe(1200); + }); + + it('overrides artifacts via NW_QA_ARTIFACTS', () => { + process.env.NW_QA_ARTIFACTS = 'video'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.artifacts).toBe('video'); + }); + + it('ignores invalid enum value for NW_QA_ARTIFACTS', () => { + process.env.NW_QA_ARTIFACTS = 'invalid'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).toBeNull(); + }); + + it('overrides branchPatterns via NW_QA_BRANCH_PATTERNS (comma-separated)', () => { + process.env.NW_QA_BRANCH_PATTERNS = 'feat/,fix/,hotfix/'; + const qaDef = getJobDef('qa')!; + const result = buildJobEnvOverrides( + qaDef.envPrefix, + qaDef.defaultConfig as Record, + qaDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.branchPatterns).toEqual(['feat/', 'fix/', 'hotfix/']); + }); + + it('overrides analytics lookbackDays via NW_ANALYTICS_LOOKBACK_DAYS', () => { + process.env.NW_ANALYTICS_LOOKBACK_DAYS = '30'; + const analyticsDef = getJobDef('analytics')!; + const result = buildJobEnvOverrides( + analyticsDef.envPrefix, + analyticsDef.defaultConfig as Record, + analyticsDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.lookbackDays).toBe(30); + }); + + it('overrides audit enabled via NW_AUDIT_ENABLED', () => { + process.env.NW_AUDIT_ENABLED = '1'; + const auditDef = getJobDef('audit')!; + const result = buildJobEnvOverrides( + auditDef.envPrefix, + auditDef.defaultConfig as Record, + auditDef.extraFields, + ); + expect(result).not.toBeNull(); + expect(result!.enabled).toBe(true); + }); +}); diff --git a/packages/core/src/config-env.ts b/packages/core/src/config-env.ts index ba17752c..6bfc2f39 100644 --- a/packages/core/src/config-env.ts +++ b/packages/core/src/config-env.ts @@ -5,22 +5,15 @@ import { ClaudeModel, - IAnalyticsConfig, - IAuditConfig, IJobProviders, INightWatchConfig, INotificationConfig, - IQaConfig, IQueueConfig, IRoadmapScannerConfig, JobType, Provider, - QaArtifacts, } from './types.js'; import { - DEFAULT_ANALYTICS, - DEFAULT_AUDIT, - DEFAULT_QA, DEFAULT_QUEUE, DEFAULT_ROADMAP_SCANNER, VALID_CLAUDE_MODELS, @@ -28,6 +21,7 @@ import { VALID_MERGE_METHODS, } from './constants.js'; import { validateProvider } from './config-normalize.js'; +import { buildJobEnvOverrides, getJobDef } from './jobs/job-registry.js'; function parseBoolean(value: string): boolean | null { const v = value.toLowerCase().trim(); @@ -197,74 +191,23 @@ export function buildEnvOverrideConfig( } } - // QA env vars - const qaBase = (): IQaConfig => env.qa ?? fileConfig?.qa ?? DEFAULT_QA; - - if (process.env.NW_QA_ENABLED) { - const v = parseBoolean(process.env.NW_QA_ENABLED); - if (v !== null) env.qa = { ...qaBase(), enabled: v }; - } - if (process.env.NW_QA_SCHEDULE) { - env.qa = { ...qaBase(), schedule: process.env.NW_QA_SCHEDULE }; - } - if (process.env.NW_QA_MAX_RUNTIME) { - const v = parseInt(process.env.NW_QA_MAX_RUNTIME, 10); - if (!isNaN(v) && v > 0) env.qa = { ...qaBase(), maxRuntime: v }; - } - if (process.env.NW_QA_ARTIFACTS) { - const a = process.env.NW_QA_ARTIFACTS; - if (['screenshot', 'video', 'both'].includes(a)) { - env.qa = { ...qaBase(), artifacts: a as QaArtifacts }; + // Registry-driven env overrides for nested job configs (qa, audit, analytics) + // Executor/reviewer use flat top-level fields; slicer/roadmapScanner handled above + for (const jobId of ['qa', 'audit', 'analytics'] as const) { + const jobDef = getJobDef(jobId); + if (!jobDef) continue; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const currentBase = (env as any)[jobId] ?? (fileConfig as any)?.[jobId] ?? jobDef.defaultConfig; + const overrides = buildJobEnvOverrides( + jobDef.envPrefix, + currentBase as Record, + jobDef.extraFields, + ); + if (overrides) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (env as any)[jobId] = overrides; } } - if (process.env.NW_QA_SKIP_LABEL) { - env.qa = { ...qaBase(), skipLabel: process.env.NW_QA_SKIP_LABEL }; - } - if (process.env.NW_QA_AUTO_INSTALL_PLAYWRIGHT) { - const v = parseBoolean(process.env.NW_QA_AUTO_INSTALL_PLAYWRIGHT); - if (v !== null) env.qa = { ...qaBase(), autoInstallPlaywright: v }; - } - if (process.env.NW_QA_BRANCH_PATTERNS) { - const patterns = process.env.NW_QA_BRANCH_PATTERNS.split(',') - .map((s) => s.trim()) - .filter(Boolean); - if (patterns.length > 0) env.qa = { ...qaBase(), branchPatterns: patterns }; - } - - // Audit env vars - const auditBase = (): IAuditConfig => env.audit ?? fileConfig?.audit ?? DEFAULT_AUDIT; - - if (process.env.NW_AUDIT_ENABLED) { - const v = parseBoolean(process.env.NW_AUDIT_ENABLED); - if (v !== null) env.audit = { ...auditBase(), enabled: v }; - } - if (process.env.NW_AUDIT_SCHEDULE) { - env.audit = { ...auditBase(), schedule: process.env.NW_AUDIT_SCHEDULE }; - } - if (process.env.NW_AUDIT_MAX_RUNTIME) { - const v = parseInt(process.env.NW_AUDIT_MAX_RUNTIME, 10); - if (!isNaN(v) && v > 0) env.audit = { ...auditBase(), maxRuntime: v }; - } - - // Analytics env vars - const analyticsBase = (): IAnalyticsConfig => - env.analytics ?? fileConfig?.analytics ?? DEFAULT_ANALYTICS; - - if (process.env.NW_ANALYTICS_ENABLED) { - const v = parseBoolean(process.env.NW_ANALYTICS_ENABLED); - if (v !== null) env.analytics = { ...analyticsBase(), enabled: v }; - } - if (process.env.NW_ANALYTICS_SCHEDULE) { - env.analytics = { ...analyticsBase(), schedule: process.env.NW_ANALYTICS_SCHEDULE }; - } - if (process.env.NW_ANALYTICS_MAX_RUNTIME) { - const v = parseInt(process.env.NW_ANALYTICS_MAX_RUNTIME, 10); - if (!isNaN(v) && v > 0) env.analytics = { ...analyticsBase(), maxRuntime: v }; - } - if (process.env.NW_ANALYTICS_LOOKBACK_DAYS) { - const v = parseInt(process.env.NW_ANALYTICS_LOOKBACK_DAYS, 10); - if (!isNaN(v) && v > 0) env.analytics = { ...analyticsBase(), lookbackDays: v }; - } // Per-job provider overrides (NW_JOB_PROVIDER_) const jobProvidersEnv: IJobProviders = {}; diff --git a/packages/core/src/config-normalize.ts b/packages/core/src/config-normalize.ts index e0134a29..b8ed0c6d 100644 --- a/packages/core/src/config-normalize.ts +++ b/packages/core/src/config-normalize.ts @@ -3,21 +3,13 @@ * Handles legacy nested keys and validates field values. */ -import { - BOARD_COLUMNS, - BoardColumnName, - BoardProviderType, - IBoardProviderConfig, -} from './board/types.js'; +import { BoardProviderType, IBoardProviderConfig } from './board/types.js'; import { ClaudeModel, - IAnalyticsConfig, - IAuditConfig, IJobProviders, INightWatchConfig, IProviderBucketConfig, IProviderPreset, - IQaConfig, IQueueConfig, IRoadmapScannerConfig, IWebhookConfig, @@ -25,21 +17,18 @@ import { MergeMethod, NotificationEvent, Provider, - QaArtifacts, QueueMode, WebhookType, } from './types.js'; import { - DEFAULT_ANALYTICS, - DEFAULT_AUDIT, DEFAULT_BOARD_PROVIDER, - DEFAULT_QA, DEFAULT_QUEUE, DEFAULT_ROADMAP_SCANNER, VALID_CLAUDE_MODELS, VALID_JOB_TYPES, VALID_MERGE_METHODS, } from './constants.js'; +import { getJobDef, normalizeJobConfig } from './jobs/job-registry.js'; export function validateProvider(value: string): Provider | null { // Accept any non-empty string as a preset ID (backward compat with 'claude'/'codex') @@ -266,54 +255,16 @@ export function normalizeConfig(rawConfig: Record): Partial = { - executor: EXECUTOR_LOG_NAME, - reviewer: REVIEWER_LOG_NAME, - qa: QA_LOG_NAME, - audit: AUDIT_LOG_NAME, - planner: PLANNER_LOG_NAME, - analytics: ANALYTICS_LOG_NAME, -}; +// Mapping from logical API names to actual file names (derived from JOB_REGISTRY) +export const LOG_FILE_NAMES: Record = getLogFileNames(); // Global Registry export const GLOBAL_CONFIG_DIR = '.night-watch'; @@ -295,14 +282,8 @@ export const DEFAULT_QUEUE_ENABLED = true; export const DEFAULT_QUEUE_MODE: QueueMode = 'conservative'; export const DEFAULT_QUEUE_MAX_CONCURRENCY = 1; export const DEFAULT_QUEUE_MAX_WAIT_TIME = 7200; // 2 hours in seconds -export const DEFAULT_QUEUE_PRIORITY: Record = { - executor: 50, - reviewer: 40, - slicer: 30, - qa: 20, - audit: 10, - analytics: 10, -}; +// Default per-job queue priority mapping (derived from JOB_REGISTRY) +export const DEFAULT_QUEUE_PRIORITY: Record = getDefaultQueuePriority(); export const DEFAULT_QUEUE: IQueueConfig = { enabled: DEFAULT_QUEUE_ENABLED, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 56b00f9b..50bddd79 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -48,3 +48,4 @@ export * from './templates/slicer-prompt.js'; // Note: shared/types are re-exported selectively through types.ts to avoid duplicates. // Import directly from '@night-watch/core/shared/types.js' if you need the full shared API contract. export type { IRoadmapContextOptions } from './shared/types.js'; +export * from './jobs/index.js'; diff --git a/packages/core/src/jobs/index.ts b/packages/core/src/jobs/index.ts new file mode 100644 index 00000000..214e937c --- /dev/null +++ b/packages/core/src/jobs/index.ts @@ -0,0 +1,17 @@ +// Jobs module — Job Registry pattern for scalable job architecture + +export type { IBaseJobConfig, IExtraFieldDef, IJobDefinition } from './job-registry.js'; +export { + JOB_REGISTRY, + getAllJobDefs, + getJobDef, + getJobDefByCommand, + getJobDefByLogName, + getValidJobTypes, + getDefaultQueuePriority, + getLogFileNames, + getLockSuffix, + normalizeJobConfig, + camelToUpperSnake, + buildJobEnvOverrides, +} from './job-registry.js'; diff --git a/packages/core/src/jobs/job-registry.ts b/packages/core/src/jobs/job-registry.ts new file mode 100644 index 00000000..d3a968cf --- /dev/null +++ b/packages/core/src/jobs/job-registry.ts @@ -0,0 +1,530 @@ +/** + * Job Registry — Single source of truth for job metadata, defaults, and config patterns. + * Adding a new job type only requires adding an entry to JOB_REGISTRY. + */ + +import { JobType } from '../types.js'; + +/** + * Base configuration interface that all job configs extend. + * Provides uniform access patterns for enabled/schedule/maxRuntime. + */ +export interface IBaseJobConfig { + /** Whether the job is enabled */ + enabled: boolean; + /** Cron schedule for the job */ + schedule: string; + /** Maximum runtime in seconds */ + maxRuntime: number; +} + +/** + * Definition for extra fields beyond the base { enabled, schedule, maxRuntime } + */ +export interface IExtraFieldDef { + /** Field name in the config object */ + name: string; + /** Type of the field for validation */ + type: 'string' | 'number' | 'boolean' | 'string[]' | 'enum'; + /** Valid values for enum type */ + enumValues?: string[]; + /** Default value if not specified */ + defaultValue: unknown; +} + +/** + * Complete definition for a job type in the registry. + */ +export interface IJobDefinition { + /** Job type identifier (matches JobType union) */ + id: JobType; + /** Human-readable name (e.g., "Executor", "QA", "Auditor") */ + name: string; + /** Short description of what the job does */ + description: string; + /** CLI command to invoke this job (e.g., "run", "review", "qa") */ + cliCommand: string; + /** Log file name without extension (e.g., "executor", "night-watch-qa") */ + logName: string; + /** Lock file suffix (e.g., ".lock", "-r.lock", "-qa.lock") */ + lockSuffix: string; + /** Queue priority (higher = runs first) */ + queuePriority: number; + /** Env var prefix for NW_* overrides (e.g., "NW_EXECUTOR", "NW_QA") */ + envPrefix: string; + /** Extra config fields beyond base (e.g., QA's branchPatterns, artifacts) */ + extraFields?: IExtraFieldDef[]; + /** Default configuration values */ + defaultConfig: TConfig; + /** + * Legacy config migration: reads old flat/nested config shapes and extracts job config. + * Returns undefined if no legacy fields are present. + */ + migrateLegacy?: (raw: Record) => Partial | undefined; +} + +/** + * Job registry containing all job type definitions. + * This is the single source of truth for job metadata. + */ +export const JOB_REGISTRY: IJobDefinition[] = [ + { + id: 'executor', + name: 'Executor', + description: 'Creates implementation PRs from PRDs', + cliCommand: 'run', + logName: 'executor', + lockSuffix: '.lock', + queuePriority: 50, + envPrefix: 'NW_EXECUTOR', + defaultConfig: { + enabled: true, + schedule: '5 */2 * * *', + maxRuntime: 7200, + }, + migrateLegacy: (raw): Partial | undefined => { + const result: Partial = {}; + let hasLegacy = false; + + if (typeof raw.executorEnabled === 'boolean') { + result.enabled = raw.executorEnabled; + hasLegacy = true; + } + if (typeof raw.cronSchedule === 'string') { + result.schedule = raw.cronSchedule; + hasLegacy = true; + } + if (typeof raw.maxRuntime === 'number') { + result.maxRuntime = raw.maxRuntime; + hasLegacy = true; + } + + return hasLegacy ? result : undefined; + }, + }, + { + id: 'reviewer', + name: 'Reviewer', + description: 'Reviews and improves PRs on night-watch branches', + cliCommand: 'review', + logName: 'reviewer', + lockSuffix: '-r.lock', + queuePriority: 40, + envPrefix: 'NW_REVIEWER', + defaultConfig: { + enabled: true, + schedule: '25 */3 * * *', + maxRuntime: 3600, + }, + migrateLegacy: (raw): Partial | undefined => { + const result: Partial = {}; + let hasLegacy = false; + + if (typeof raw.reviewerEnabled === 'boolean') { + result.enabled = raw.reviewerEnabled; + hasLegacy = true; + } + if (typeof raw.reviewerSchedule === 'string') { + result.schedule = raw.reviewerSchedule; + hasLegacy = true; + } + if (typeof raw.reviewerMaxRuntime === 'number') { + result.maxRuntime = raw.reviewerMaxRuntime; + hasLegacy = true; + } + + return hasLegacy ? result : undefined; + }, + }, + { + id: 'slicer', + name: 'Slicer', + description: 'Generates PRDs from roadmap items', + cliCommand: 'planner', + logName: 'slicer', + lockSuffix: '-slicer.lock', + queuePriority: 30, + envPrefix: 'NW_SLICER', + defaultConfig: { + enabled: true, + schedule: '35 */6 * * *', + maxRuntime: 600, + }, + migrateLegacy: (raw): Partial | undefined => { + const roadmapScanner = raw.roadmapScanner as Record | undefined; + if (!roadmapScanner) return undefined; + + const result: Partial = {}; + let hasLegacy = false; + + if (typeof roadmapScanner.enabled === 'boolean') { + result.enabled = roadmapScanner.enabled; + hasLegacy = true; + } + if (typeof roadmapScanner.slicerSchedule === 'string') { + result.schedule = roadmapScanner.slicerSchedule; + hasLegacy = true; + } + if (typeof roadmapScanner.slicerMaxRuntime === 'number') { + result.maxRuntime = roadmapScanner.slicerMaxRuntime; + hasLegacy = true; + } + + return hasLegacy ? result : undefined; + }, + }, + { + id: 'qa', + name: 'QA', + description: 'Runs end-to-end tests on PRs', + cliCommand: 'qa', + logName: 'night-watch-qa', + lockSuffix: '-qa.lock', + queuePriority: 20, + envPrefix: 'NW_QA', + extraFields: [ + { name: 'branchPatterns', type: 'string[]', defaultValue: [] }, + { + name: 'artifacts', + type: 'enum', + enumValues: ['screenshot', 'video', 'both'], + defaultValue: 'both', + }, + { name: 'skipLabel', type: 'string', defaultValue: 'skip-qa' }, + { name: 'autoInstallPlaywright', type: 'boolean', defaultValue: true }, + ], + defaultConfig: { + enabled: true, + schedule: '45 2,10,18 * * *', + maxRuntime: 3600, + branchPatterns: [], + artifacts: 'both', + skipLabel: 'skip-qa', + autoInstallPlaywright: true, + } as IBaseJobConfig & { + branchPatterns: string[]; + artifacts: string; + skipLabel: string; + autoInstallPlaywright: boolean; + }, + migrateLegacy: (raw): Partial | undefined => { + const qa = raw.qa as Record | undefined; + if (!qa) return undefined; + + // If qa object exists with base fields, it's already in new format + if ( + typeof qa.enabled === 'boolean' || + typeof qa.schedule === 'string' || + typeof qa.maxRuntime === 'number' + ) { + return { + enabled: typeof qa.enabled === 'boolean' ? qa.enabled : undefined, + schedule: typeof qa.schedule === 'string' ? qa.schedule : undefined, + maxRuntime: typeof qa.maxRuntime === 'number' ? qa.maxRuntime : undefined, + } as Partial; + } + return undefined; + }, + }, + { + id: 'audit', + name: 'Auditor', + description: 'Performs code audits and creates issues for findings', + cliCommand: 'audit', + logName: 'audit', + lockSuffix: '-audit.lock', + queuePriority: 10, + envPrefix: 'NW_AUDIT', + defaultConfig: { + enabled: true, + schedule: '50 3 * * 1', + maxRuntime: 1800, + }, + migrateLegacy: (raw): Partial | undefined => { + const audit = raw.audit as Record | undefined; + if (!audit) return undefined; + + if ( + typeof audit.enabled === 'boolean' || + typeof audit.schedule === 'string' || + typeof audit.maxRuntime === 'number' + ) { + return { + enabled: typeof audit.enabled === 'boolean' ? audit.enabled : undefined, + schedule: typeof audit.schedule === 'string' ? audit.schedule : undefined, + maxRuntime: typeof audit.maxRuntime === 'number' ? audit.maxRuntime : undefined, + } as Partial; + } + return undefined; + }, + }, + { + id: 'analytics', + name: 'Analytics', + description: 'Analyzes product analytics and creates issues for trends', + cliCommand: 'analytics', + logName: 'analytics', + lockSuffix: '-analytics.lock', + queuePriority: 10, + envPrefix: 'NW_ANALYTICS', + extraFields: [ + { name: 'lookbackDays', type: 'number', defaultValue: 7 }, + { + name: 'targetColumn', + type: 'enum', + enumValues: ['Draft', 'Ready', 'In Progress', 'Done', 'Closed'], + defaultValue: 'Draft', + }, + { name: 'analysisPrompt', type: 'string', defaultValue: '' }, + ], + defaultConfig: { + enabled: false, + schedule: '0 6 * * 1', + maxRuntime: 900, + lookbackDays: 7, + targetColumn: 'Draft', + analysisPrompt: '', + } as IBaseJobConfig & { lookbackDays: number; targetColumn: string; analysisPrompt: string }, + migrateLegacy: (raw): Partial | undefined => { + const analytics = raw.analytics as Record | undefined; + if (!analytics) return undefined; + + if ( + typeof analytics.enabled === 'boolean' || + typeof analytics.schedule === 'string' || + typeof analytics.maxRuntime === 'number' + ) { + return { + enabled: typeof analytics.enabled === 'boolean' ? analytics.enabled : undefined, + schedule: typeof analytics.schedule === 'string' ? analytics.schedule : undefined, + maxRuntime: typeof analytics.maxRuntime === 'number' ? analytics.maxRuntime : undefined, + } as Partial; + } + return undefined; + }, + }, +]; + +/** + * Map of job ID to job definition for O(1) lookup. + */ +const JOB_MAP: Map = new Map(JOB_REGISTRY.map((job) => [job.id, job])); + +/** + * Get a job definition by its ID. + */ +export function getJobDef(id: JobType): IJobDefinition | undefined { + return JOB_MAP.get(id); +} + +/** + * Get all job definitions. + */ +export function getAllJobDefs(): IJobDefinition[] { + return [...JOB_REGISTRY]; +} + +/** + * Get a job definition by its CLI command. + */ +export function getJobDefByCommand(command: string): IJobDefinition | undefined { + return JOB_REGISTRY.find((job) => job.cliCommand === command); +} + +/** + * Get a job definition by its log name. + */ +export function getJobDefByLogName(logName: string): IJobDefinition | undefined { + return JOB_REGISTRY.find((job) => job.logName === logName); +} + +/** + * Get all valid job types (derived from registry). + */ +export function getValidJobTypes(): JobType[] { + return JOB_REGISTRY.map((job) => job.id); +} + +/** + * Get the default queue priority mapping (derived from registry). + */ +export function getDefaultQueuePriority(): Record { + const result: Record = {}; + for (const job of JOB_REGISTRY) { + result[job.id] = job.queuePriority; + } + return result; +} + +/** + * Get the log file names mapping (derived from registry). + * Maps from CLI command / API name to actual log file name. + */ +export function getLogFileNames(): Record { + const result: Record = {}; + for (const job of JOB_REGISTRY) { + // Map both id and cliCommand to logName for backward compat + result[job.id] = job.logName; + if (job.cliCommand !== job.id) { + result[job.cliCommand] = job.logName; + } + } + return result; +} + +/** + * Get the lock file suffix for a job. + */ +export function getLockSuffix(jobId: JobType): string { + return getJobDef(jobId)?.lockSuffix ?? '.lock'; +} + +/** + * Normalize a raw job config object using the job definition's schema. + * Applies base fields (enabled, schedule, maxRuntime) + extra fields with type validation. + */ +export function normalizeJobConfig( + raw: Record, + jobDef: IJobDefinition, +): Record { + const readBoolean = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined); + const readString = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); + const readNumber = (v: unknown): number | undefined => + typeof v === 'number' && !Number.isNaN(v) ? v : undefined; + const readStringArray = (v: unknown): string[] | undefined => + Array.isArray(v) && v.every((s) => typeof s === 'string') ? (v as string[]) : undefined; + + const defaults = jobDef.defaultConfig as unknown as Record; + const result: Record = { + enabled: readBoolean(raw.enabled) ?? defaults.enabled, + schedule: readString(raw.schedule) ?? defaults.schedule, + maxRuntime: readNumber(raw.maxRuntime) ?? defaults.maxRuntime, + }; + + for (const field of jobDef.extraFields ?? []) { + switch (field.type) { + case 'boolean': + result[field.name] = readBoolean(raw[field.name]) ?? field.defaultValue; + break; + case 'string': + result[field.name] = readString(raw[field.name]) ?? field.defaultValue; + break; + case 'number': + result[field.name] = readNumber(raw[field.name]) ?? field.defaultValue; + break; + case 'string[]': + result[field.name] = readStringArray(raw[field.name]) ?? field.defaultValue; + break; + case 'enum': { + const val = readString(raw[field.name]); + result[field.name] = val && field.enumValues?.includes(val) ? val : field.defaultValue; + break; + } + } + } + + return result; +} + +/** + * Convert a camelCase field name to UPPER_SNAKE_CASE for env var lookup. + * e.g., "lookbackDays" → "LOOKBACK_DAYS" + */ +export function camelToUpperSnake(name: string): string { + return name.replace(/([A-Z])/g, '_$1').toUpperCase(); +} + +/** + * Build env variable overrides for a job from NW_* environment variables. + * Returns null if no env vars were set for this job. + * + * Naming convention: {envPrefix}_{FIELD_UPPER_SNAKE} + * e.g., envPrefix='NW_QA', field='branchPatterns' → 'NW_QA_BRANCH_PATTERNS' + */ +export function buildJobEnvOverrides( + envPrefix: string, + currentBase: Record, + extraFields?: IExtraFieldDef[], +): Record | null { + const parseBoolean = (value: string): boolean | null => { + const v = value.toLowerCase().trim(); + if (v === 'true' || v === '1') return true; + if (v === 'false' || v === '0') return false; + return null; + }; + + const result = { ...currentBase }; + let changed = false; + + // Base fields + const enabledVal = process.env[`${envPrefix}_ENABLED`]; + if (enabledVal) { + const v = parseBoolean(enabledVal); + if (v !== null) { + result.enabled = v; + changed = true; + } + } + const scheduleVal = process.env[`${envPrefix}_SCHEDULE`]; + if (scheduleVal) { + result.schedule = scheduleVal; + changed = true; + } + const maxRuntimeVal = process.env[`${envPrefix}_MAX_RUNTIME`]; + if (maxRuntimeVal) { + const v = parseInt(maxRuntimeVal, 10); + if (!isNaN(v) && v > 0) { + result.maxRuntime = v; + changed = true; + } + } + + // Extra fields + for (const field of extraFields ?? []) { + const envKey = `${envPrefix}_${camelToUpperSnake(field.name)}`; + const envVal = process.env[envKey]; + if (!envVal) continue; + + switch (field.type) { + case 'boolean': { + const v = parseBoolean(envVal); + if (v !== null) { + result[field.name] = v; + changed = true; + } + break; + } + case 'string': + result[field.name] = envVal; + changed = true; + break; + case 'number': { + const v = parseInt(envVal, 10); + if (!isNaN(v) && v > 0) { + result[field.name] = v; + changed = true; + } + break; + } + case 'string[]': { + const patterns = envVal + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (patterns.length > 0) { + result[field.name] = patterns; + changed = true; + } + break; + } + case 'enum': + if (field.enumValues?.includes(envVal)) { + result[field.name] = envVal; + changed = true; + } + break; + } + } + + return changed ? result : null; +} diff --git a/packages/server/src/routes/action.routes.ts b/packages/server/src/routes/action.routes.ts index 9623f0dd..560d8bd8 100644 --- a/packages/server/src/routes/action.routes.ts +++ b/packages/server/src/routes/action.routes.ts @@ -11,6 +11,7 @@ import { Request, Response, Router } from 'express'; import { CLAIM_FILE_EXTENSION, INightWatchConfig, + JOB_REGISTRY, checkLockFile, executorLockPath, fetchStatusSnapshot, @@ -182,21 +183,15 @@ function createActionRouteHandlers(ctx: IActionRouteContext): Router { spawnAction(ctx.getProjectDir(req), ['review'], req, res); }); - router.post(`/${p}qa`, (req: Request, res: Response): void => { - spawnAction(ctx.getProjectDir(req), ['qa'], req, res); - }); - - router.post(`/${p}audit`, (req: Request, res: Response): void => { - spawnAction(ctx.getProjectDir(req), ['audit'], req, res); - }); - - router.post(`/${p}analytics`, (req: Request, res: Response): void => { - spawnAction(ctx.getProjectDir(req), ['analytics'], req, res); - }); - - router.post(`/${p}planner`, (req: Request, res: Response): void => { - spawnAction(ctx.getProjectDir(req), ['planner'], req, res); - }); + // Registry-driven job routes (all jobs except executor and reviewer which are handled above) + for (const jobDef of JOB_REGISTRY) { + if (jobDef.id === 'executor') continue; // handled above (has SSE broadcast) + if (jobDef.id === 'reviewer') continue; // handled above + const cmd = jobDef.cliCommand; + router.post(`/${p}${cmd}`, (req: Request, res: Response): void => { + spawnAction(ctx.getProjectDir(req), [cmd], req, res); + }); + } router.post(`/${p}install-cron`, (req: Request, res: Response): void => { const projectDir = ctx.getProjectDir(req); diff --git a/web/api.ts b/web/api.ts index 9338c1b2..174893f7 100644 --- a/web/api.ts +++ b/web/api.ts @@ -312,6 +312,27 @@ export function triggerPlanner(): Promise { }); } +/** + * Generic job trigger using the web job registry. + * Prefer this over per-job triggerRun/triggerReview/etc. for new code. + */ +export function triggerJob(jobId: string): Promise { + // Map job IDs to their API action endpoints + const endpointMap: Record = { + executor: '/api/actions/run', + reviewer: '/api/actions/review', + qa: '/api/actions/qa', + audit: '/api/actions/audit', + slicer: '/api/actions/planner', + analytics: '/api/actions/analytics', + }; + const endpoint = endpointMap[jobId]; + if (!endpoint) { + return Promise.reject(new Error(`Unknown job ID: ${jobId}`)); + } + return apiFetch(apiPath(endpoint), { method: 'POST' }); +} + export function triggerInstallCron(): Promise { return apiFetch(apiPath('/api/actions/install-cron'), { method: 'POST', diff --git a/web/pages/Scheduling.tsx b/web/pages/Scheduling.tsx index f31973bf..50d122ad 100644 --- a/web/pages/Scheduling.tsx +++ b/web/pages/Scheduling.tsx @@ -29,12 +29,7 @@ import { updateConfig, triggerInstallCron, triggerUninstallCron, - triggerRun, - triggerReview, - triggerQa, - triggerAudit, - triggerPlanner, - triggerAnalytics, + triggerJob, useApi, } from '../api'; import { @@ -46,6 +41,7 @@ import { isWithin30Minutes, } from '../utils/cron'; import type { IScheduleTemplate } from '../utils/cron.js'; +import { getWebJobDef } from '../utils/jobs'; interface IScheduleEditState { form: IScheduleConfigForm; @@ -199,27 +195,17 @@ const Scheduling: React.FC = () => { } }; const handleJobToggle = async ( - job: 'executor' | 'reviewer' | 'qa' | 'audit' | 'planner' | 'analytics', + jobId: string, enabled: boolean, ) => { if (!config) return; - setUpdatingJob(job); + // Map legacy 'planner' ID to registry 'slicer' ID + const registryId = jobId === 'planner' ? 'slicer' : jobId; + const jobDef = getWebJobDef(registryId); + if (!jobDef) return; + setUpdatingJob(jobId); try { - if (job === 'executor') { - await updateConfig({ executorEnabled: enabled }); - } else if (job === 'reviewer') { - await updateConfig({ reviewerEnabled: enabled }); - } else if (job === 'qa') { - await updateConfig({ qa: { ...config.qa, enabled } }); - } else if (job === 'audit') { - await updateConfig({ audit: { ...config.audit, enabled } }); - } else if (job === 'analytics') { - await updateConfig({ analytics: { ...config.analytics, enabled } }); - } else { - await updateConfig({ - roadmapScanner: { ...config.roadmapScanner, enabled }, - }); - } + await updateConfig(jobDef.buildEnabledPatch(enabled, config)); let cronInstallFailedMessage = ''; try { await triggerInstallCron(); @@ -239,7 +225,7 @@ const Scheduling: React.FC = () => { } : { title: 'Job Updated', - message: `${job[0].toUpperCase() + job.slice(1)} ${enabled ? 'enabled' : 'disabled'}.`, + message: `${jobId[0].toUpperCase() + jobId.slice(1)} ${enabled ? 'enabled' : 'disabled'}.`, type: 'success', }, ); @@ -253,28 +239,22 @@ const Scheduling: React.FC = () => { setUpdatingJob(null); } }; - const handleTriggerJob = async (job: 'executor' | 'reviewer' | 'qa' | 'audit' | 'planner' | 'analytics') => { - setTriggeringJob(job); + const handleTriggerJob = async (jobId: string) => { + // Map legacy 'planner' ID to registry 'slicer' ID + const registryId = jobId === 'planner' ? 'slicer' : jobId; + setTriggeringJob(jobId); try { - const triggerMap = { - executor: triggerRun, - reviewer: triggerReview, - qa: triggerQa, - audit: triggerAudit, - planner: triggerPlanner, - analytics: triggerAnalytics, - }; - await triggerMap[job](); + await triggerJob(registryId); addToast({ title: 'Job Triggered', - message: `${job[0].toUpperCase() + job.slice(1)} job has been queued.`, + message: `${jobId[0].toUpperCase() + jobId.slice(1)} job has been queued.`, type: 'success', }); refetchSchedule(); } catch (error) { addToast({ title: 'Trigger Failed', - message: formatErrorMessage(error, `Failed to trigger ${job} job`), + message: formatErrorMessage(error, `Failed to trigger ${jobId} job`), type: 'error', }); } finally { @@ -632,7 +612,7 @@ const Scheduling: React.FC = () => { checked={agent.enabled} disabled={updatingJob !== null} aria-label={`Toggle ${agent.name.toLowerCase()} automation`} - onChange={(checked) => handleJobToggle(agent.id as 'executor' | 'reviewer' | 'qa' | 'audit' | 'planner' | 'analytics', checked)} + onChange={(checked) => handleJobToggle(agent.id, checked)} /> @@ -676,7 +656,7 @@ const Scheduling: React.FC = () => {