diff --git a/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts b/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts index 6fe7e71..e9f1c23 100644 --- a/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts +++ b/packages/cli/src/__tests__/scripts/core-flow-smoke.test.ts @@ -1539,6 +1539,12 @@ describe('core flow smoke tests (bash scripts)', () => { { encoding: 'utf-8', mode: 0o755 }, ); + fs.writeFileSync( + path.join(fakeBin, 'claude'), + '#!/usr/bin/env bash\nexit 0\n', + { encoding: 'utf-8', mode: 0o755 }, + ); + const result = runScript(reviewerScript, projectDir, { PATH: `${fakeBin}:${process.env.PATH}`, NW_PROVIDER_CMD: 'claude', @@ -1596,6 +1602,12 @@ describe('core flow smoke tests (bash scripts)', () => { { encoding: 'utf-8', mode: 0o755 }, ); + fs.writeFileSync( + path.join(fakeBin, 'claude'), + '#!/usr/bin/env bash\nexit 0\n', + { encoding: 'utf-8', mode: 0o755 }, + ); + const result = runScript(reviewerScript, projectDir, { PATH: `${fakeBin}:${process.env.PATH}`, NW_PROVIDER_CMD: 'claude', @@ -1748,6 +1760,12 @@ describe('core flow smoke tests (bash scripts)', () => { { encoding: 'utf-8', mode: 0o755 }, ); + fs.writeFileSync( + path.join(fakeBin, 'claude'), + '#!/usr/bin/env bash\nexit 0\n', + { encoding: 'utf-8', mode: 0o755 }, + ); + const result = runScript(reviewerScript, projectDir, { PATH: `${fakeBin}:${process.env.PATH}`, NW_PROVIDER_CMD: 'claude', diff --git a/packages/cli/src/commands/review.ts b/packages/cli/src/commands/review.ts index 840449e..d0027f4 100644 --- a/packages/cli/src/commands/review.ts +++ b/packages/cli/src/commands/review.ts @@ -166,8 +166,7 @@ export function postReadyForHumanReviewComment( finalScore: number | undefined, cwd: string, ): void { - const scoreNote = - finalScore !== undefined ? ` (score: ${finalScore}/100)` : ''; + const scoreNote = finalScore !== undefined ? ` (score: ${finalScore}/100)` : ''; const body = `## โœ… Ready for Human Review\n\n` + `Night Watch has reviewed this PR${scoreNote} and found no issues requiring automated fixes.\n\n` + @@ -520,12 +519,16 @@ export function reviewCommand(program: Command): void { const reviewedPrNumbers = parseReviewedPrNumbers(scriptResult?.data.prs); const noChangesPrNumbers = parseReviewedPrNumbers(scriptResult?.data.no_changes_prs); const fallbackPrNumber = fallbackPrDetails?.number; + let primaryPrNumbers: number[]; + if (reviewedPrNumbers.length > 0) { + primaryPrNumbers = reviewedPrNumbers; + } else if (fallbackPrNumber !== undefined) { + primaryPrNumbers = [fallbackPrNumber]; + } else { + primaryPrNumbers = []; + } const notificationTargets = buildReviewNotificationTargets( - reviewedPrNumbers.length > 0 - ? reviewedPrNumbers - : fallbackPrNumber !== undefined - ? [fallbackPrNumber] - : [], + primaryPrNumbers, noChangesPrNumbers, legacyNoChangesNeeded, ); @@ -567,7 +570,10 @@ export function reviewCommand(program: Command): void { event: reviewEvent, projectName: path.basename(projectDir), exitCode, - provider: formatProviderDisplay(envVars.NW_PROVIDER_CMD, envVars.NW_PROVIDER_LABEL), + provider: formatProviderDisplay( + envVars.NW_PROVIDER_CMD, + envVars.NW_PROVIDER_LABEL, + ), prUrl: prDetails?.url, prTitle: prDetails?.title, prBody: prDetails?.body, diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a3e7fde..cf0329a 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -21,7 +21,6 @@ import { DEFAULT_ANALYTICS, DEFAULT_AUDIT, DEFAULT_AUTO_MERGE, - DEFAULT_MERGER, DEFAULT_AUTO_MERGE_METHOD, DEFAULT_BOARD_PROVIDER, DEFAULT_BRANCH_PATTERNS, @@ -36,6 +35,7 @@ import { DEFAULT_MAX_LOG_SIZE, DEFAULT_MAX_RETRIES, DEFAULT_MAX_RUNTIME, + DEFAULT_MERGER, DEFAULT_MIN_REVIEW_SCORE, DEFAULT_NOTIFICATIONS, DEFAULT_PRD_DIR, @@ -220,7 +220,9 @@ function mergeConfigs( merged.merger = { ...merged.merger, enabled: true, - mergeMethod: (merged as unknown as Record).autoMergeMethod as IMergerConfig['mergeMethod'] ?? 'squash', + mergeMethod: + ((merged as unknown as Record) + .autoMergeMethod as IMergerConfig['mergeMethod']) ?? 'squash', }; } diff --git a/web/components/scheduling/QueueTab.tsx b/web/components/scheduling/QueueTab.tsx new file mode 100644 index 0000000..0794883 --- /dev/null +++ b/web/components/scheduling/QueueTab.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { AlertCircle } from 'lucide-react'; +import Card from '../ui/Card.js'; +import type { IQueueAnalytics, IQueueStatus } from '../../api.js'; +import ProviderLanesChart from './ProviderLanesChart.js'; +import ProviderBucketSummary from './ProviderBucketSummary.js'; +import RecentRunsChart from './RecentRunsChart.js'; + +interface IQueueTabProps { + queueStatus: IQueueStatus | null; + queueAnalytics: IQueueAnalytics | null; + queueStatusError: Error | null; + queueAnalyticsError: Error | null; +} + +const QueueTab: React.FC = ({ + queueStatus, + queueAnalytics, + queueStatusError, + queueAnalyticsError, +}) => { + return ( +
+ {/* Queue Overview Card */} + +

Queue Overview

+ {queueStatusError ? ( +
+ + Failed to load queue status +
+ ) : ( +
+
+
Running
+
+ {queueStatus?.running ? 1 : 0} +
+ {queueStatus?.running && ( +
+ {queueStatus.running.jobType} ยท {queueStatus.running.projectName} +
+ )} +
+
+
Pending
+
+ {queueStatus?.pending.total ?? 0} +
+
+
+
Avg Wait
+
+ {queueStatus?.averageWaitSeconds != null + ? `${Math.floor(queueStatus.averageWaitSeconds / 60)}m` + : 'โ€”'} +
+
+
+
Oldest Pending
+
+ {queueStatus?.oldestPendingAge != null + ? `${Math.floor(queueStatus.oldestPendingAge / 60)}m` + : 'โ€”'} +
+
+
+ )} +
+ + {/* Provider Lanes */} + +
+
+

Provider Lanes

+

+ Running and pending jobs grouped by provider bucket +

+
+
+ {queueStatusError ? ( +
+ + Failed to load queue status +
+ ) : queueStatus ? ( + + ) : ( +
Loading queue status...
+ )} +
+ + {/* Provider Bucket Summary */} + +
+
+

Provider Buckets

+

+ Running and pending counts per provider bucket +

+
+
+ {queueAnalyticsError ? ( +
+ + Failed to load analytics +
+ ) : queueAnalytics ? ( + + ) : ( +
Loading analytics...
+ )} +
+ + {/* Recent Runs */} + +
+
+

Recent Runs

+

+ Last 24 hours of job executions +

+
+ {queueAnalytics?.averageWaitSeconds != null && ( +
+ Avg wait: {Math.floor(queueAnalytics.averageWaitSeconds / 60)}m +
+ )} +
+ {queueAnalyticsError ? ( +
+ + Failed to load analytics +
+ ) : queueAnalytics ? ( + + ) : ( +
Loading analytics...
+ )} +
+
+ ); +}; + +export default QueueTab; diff --git a/web/pages/Scheduling.tsx b/web/pages/Scheduling.tsx index c05bc9b..f80a2d2 100644 --- a/web/pages/Scheduling.tsx +++ b/web/pages/Scheduling.tsx @@ -22,6 +22,7 @@ import Select from '../components/ui/Select'; import Switch from '../components/ui/Switch'; import Tabs from '../components/ui/Tabs'; import ScheduleTimeline from '../components/scheduling/ScheduleTimeline.js'; +import QueueTab from '../components/scheduling/QueueTab.js'; import { useStore } from '../store/useStore'; import type { INightWatchConfig, IQueueAnalytics, IQueueStatus, QueueMode } from '../api'; import { @@ -94,6 +95,8 @@ const Scheduling: React.FC = () => { const [allProjectConfigs, setAllProjectConfigs] = useState>([]); const [queueStatus, setQueueStatus] = useState(null); const [queueAnalytics, setQueueAnalytics] = useState(null); + const [queueStatusError, setQueueStatusError] = useState(null); + const [queueAnalyticsError, setQueueAnalyticsError] = useState(null); const [editState, setEditState] = useState({ isDirty: false, @@ -139,11 +142,21 @@ const Scheduling: React.FC = () => { if (globalModeLoading) return; const fetchDashboard = () => { fetchQueueStatus() - .then(setQueueStatus) - .catch(() => { /* silently ignore */ }); + .then((status) => { + setQueueStatus(status); + setQueueStatusError(null); + }) + .catch((err) => { + setQueueStatusError(err instanceof Error ? err : new Error(String(err))); + }); fetchQueueAnalytics(24) - .then(setQueueAnalytics) - .catch(() => { /* silently ignore */ }); + .then((analytics) => { + setQueueAnalytics(analytics); + setQueueAnalyticsError(null); + }) + .catch((err) => { + setQueueAnalyticsError(err instanceof Error ? err : new Error(String(err))); + }); }; fetchDashboard(); const interval = setInterval(fetchDashboard, 30000); @@ -460,7 +473,7 @@ const Scheduling: React.FC = () => { return `${activeTemplate.label} - ${activeTemplate.hints[job]}`; }; - const tabs = [ + const tabs = useMemo(() => [ { id: 'overview', label: 'Overview', @@ -913,7 +926,38 @@ const Scheduling: React.FC = () => { ), }, - ]; + { + id: 'queue', + label: 'Queue', + content: ( + + ), + }, + ], [ + agents, + statusColor, + statusText, + isPaused, + scheduleInfo, + config, + allProjectConfigs, + queueStatus, + queueAnalytics, + queueStatusError, + queueAnalyticsError, + toggling, + saving, + editState, + showAddBucket, + newBucketKey, + newBucketConcurrency, + activeTemplate, + ]); return (
diff --git a/web/pages/__tests__/Scheduling.test.tsx b/web/pages/__tests__/Scheduling.test.tsx index 7efbde2..6c1e3f4 100644 --- a/web/pages/__tests__/Scheduling.test.tsx +++ b/web/pages/__tests__/Scheduling.test.tsx @@ -297,4 +297,62 @@ describe('Scheduling page', () => { expect(screen.getByRole('button', { name: 'Open Jobs' })).toBeInTheDocument(); expect(screen.queryByText('PRD Execution Schedule')).not.toBeInTheDocument(); }); + + it('renders Queue tab with queue status and analytics', async () => { + apiMocks.fetchQueueStatus.mockResolvedValue(makeQueueStatus({ + running: { + id: 1, + jobType: 'executor', + projectPath: '/path/to/test-project', + projectName: 'test-project', + providerKey: 'claude', + status: 'running', + enqueuedAt: Date.now() / 1000, + dispatchedAt: null, + priority: 50, + }, + pending: { total: 3, byType: { executor: 2, reviewer: 1 }, byProviderBucket: {} }, + })); + apiMocks.fetchQueueAnalytics.mockResolvedValue(makeQueueAnalytics({ + recentRuns: [ + { + id: 1, + jobType: 'executor', + projectPath: '/path/to/test-project', + providerKey: 'claude', + status: 'completed', + startedAt: Date.now() / 1000, + finishedAt: Date.now() / 1000, + waitSeconds: 0, + durationSeconds: 100, + throttledCount: 0, + }, + ], + byProviderBucket: { claude: { running: 1, pending: 2 } }, + })); + + renderScheduling(); + + fireEvent.click(screen.getByRole('button', { name: 'Queue' })); + + await waitFor(() => { + expect(screen.getByText('Queue Overview')).toBeInTheDocument(); + }); + + expect(screen.getByText('Provider Lanes')).toBeInTheDocument(); + expect(screen.getByText('Provider Buckets')).toBeInTheDocument(); + expect(screen.getByText('Recent Runs')).toBeInTheDocument(); + }); + + it('shows error state when queue status fails to load', async () => { + apiMocks.fetchQueueStatus.mockRejectedValue(new Error('Network error')); + + renderScheduling(); + + fireEvent.click(screen.getByRole('button', { name: 'Queue' })); + + await waitFor(() => { + expect(screen.getByText('Failed to load queue status')).toBeInTheDocument(); + }); + }); });