diff --git a/apps/dashboard/src/api/queries.ts b/apps/dashboard/src/api/queries.ts index f442a4a..22c9804 100644 --- a/apps/dashboard/src/api/queries.ts +++ b/apps/dashboard/src/api/queries.ts @@ -8,7 +8,11 @@ export const dashboardQueryKeys = { }; export function useStatsQuery() { - return useQuery({ queryKey: dashboardQueryKeys.stats, queryFn: dashboardApi.getStats }); + return useQuery({ + queryKey: dashboardQueryKeys.stats, + queryFn: dashboardApi.getStats, + refetchInterval: 15_000, + }); } export function useTimersQuery() { diff --git a/apps/dashboard/src/features/operations/operations-page.tsx b/apps/dashboard/src/features/operations/operations-page.tsx index 5d608a2..a7814f2 100644 --- a/apps/dashboard/src/features/operations/operations-page.tsx +++ b/apps/dashboard/src/features/operations/operations-page.tsx @@ -1,5 +1,5 @@ import { useEffect, useReducer, useRef, useState } from 'react'; -import type { ActivityItem, ChatSyncStatus, DashboardEvent } from '../../../../../packages/api-contracts/src/index.js'; +import type { ActivityItem, ChatSyncStatus, DashboardEvent, RedditAccountOwner } from '../../../../../packages/api-contracts/src/index.js'; import { useDashboardRealtime } from '../../app/realtime-provider.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { RedditUserText } from '../../components/reddit-user-text.js'; @@ -72,6 +72,16 @@ function connectionLabel(snapshot: RealtimeConnectionSnapshot): string { } } +export function accountOwnerFromLogData(data: unknown): RedditAccountOwner | undefined { + if (!data || typeof data !== 'object' || Array.isArray(data)) return undefined; + const accountId = Reflect.get(data, 'accountId'); + const redditUsername = Reflect.get(data, 'redditUsername'); + return typeof accountId === 'string' && accountId.length > 0 + && typeof redditUsername === 'string' && redditUsername.length > 0 + ? { accountId, redditUsername } + : undefined; +} + export interface OperationsPageProps { realtimeEnabled?: boolean; } @@ -175,9 +185,17 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps) {consoleClearNotice ?

Cleared from this browser view. Server logs were not deleted.

: null}
- {logsQuery.isPending ?

Loading logs…

: visibleEntries.length === 0 ?

No logs match the current filters.

: visibleEntries.map((entry) => ( -
{entry.level.toUpperCase()}{entry.message}
- ))} + {logsQuery.isPending ?

Loading logs…

: visibleEntries.length === 0 ?

No logs match the current filters.

: visibleEntries.map((entry) => { + const owner = accountOwnerFromLogData(entry.data); + return ( +
+ + {entry.level.toUpperCase()} + {owner ? : 'global'} + {entry.message} +
+ ); + })}
diff --git a/apps/dashboard/src/features/operations/operations.css b/apps/dashboard/src/features/operations/operations.css index 9bae0fa..02fbf7a 100644 --- a/apps/dashboard/src/features/operations/operations.css +++ b/apps/dashboard/src/features/operations/operations.css @@ -21,9 +21,11 @@ .console-controls .checkbox-control { display: flex; flex-direction: row; align-items: center; } .console-controls .checkbox-control input { min-height: auto; } .live-console { height: min(30rem, 55vh); overflow: auto; padding: .65rem; color: #cbd8d1; background: #050906; border: 1px solid var(--border); border-radius: .6rem; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .78rem; } -.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; } +.console-entry { display: grid; grid-template-columns: 11rem 3.5rem minmax(6rem, auto) minmax(0, 1fr); gap: .65rem; padding: .35rem; border-bottom: 1px solid #142019; } .console-entry time { color: #708178; } .console-entry strong { color: #8da49a; } +.console-account { color: #708178; } +.console-account a { color: var(--accent); font-weight: 700; } .console-entry.warn strong { color: var(--warning); } .console-entry.error strong { color: var(--danger); } .live-console > p { color: var(--muted); text-align: center; } diff --git a/apps/dashboard/src/features/overview/overview-page.tsx b/apps/dashboard/src/features/overview/overview-page.tsx index 0d81c2f..a5ccd57 100644 --- a/apps/dashboard/src/features/overview/overview-page.tsx +++ b/apps/dashboard/src/features/overview/overview-page.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js'; import type { BotStats, Timers } from '../../api/contracts.js'; +import type { AccountStatus } from '../../../../../packages/api-contracts/src/index.js'; import { ConfirmationDialog } from '../../components/confirmation-dialog.js'; import { RedditUserText } from '../../components/reddit-user-text.js'; import { OriginPostButton } from '../../components/origin-post.js'; @@ -55,6 +56,63 @@ function TimersPanel({ timers }: { timers: Timers }) { ); } +const sendingStateCopy: Record = { + ready: { label: 'Ready', tone: 'success', detail: 'Available for the next outbound candidate.' }, + 'bot-stopped': { label: 'Bot stopped', tone: 'neutral', detail: 'Global sending is stopped.' }, + 'runtime-stopped': { label: 'Runtime stopped', tone: 'warning', detail: 'This account runtime is not running.' }, + 'runtime-failed': { label: 'Runtime failed', tone: 'warning', detail: 'This account runtime needs attention.' }, + 'login-required': { label: 'Login required', tone: 'warning', detail: 'Refresh this account session before it can send.' }, + 'matrix-cooldown': { label: 'Matrix cooldown', tone: 'warning', detail: 'Only this account is paused by Reddit chat throttling.' }, + 'replies-prioritized': { label: 'Replies first', tone: 'neutral', detail: 'Inbound replies are being handled before new outreach.' }, + pacing: { label: 'Pacing', tone: 'neutral', detail: 'Waiting for the account sending window.' }, +}; + +function AccountStatusPanel({ accounts }: { accounts: readonly AccountStatus[] }) { + return ( +
+
+
Multi-account delivery

Account status

+ {accounts.length} configured +
+ {accounts.length === 0 ?
No accounts configured.Add an account under Settings → Access.
: ( +
+ {accounts.map((account) => { + const state = sendingStateCopy[account.sendingState]; + return ( +
+
+ + {state.label} +
+

{state.detail}

+
+
Runtime
{account.runtimeState}
+
Authentication
{account.authenticated ? 'Authenticated' : 'Unavailable'}
+
Hourly usage
{account.messagesThisHour} of {account.maxMessagesPerHour}
+
Remaining
{account.messagesRemaining}
+
Allowance resets
{formatTime(account.hourlyResetAt)}
+
Pending replies
{account.actionableReplies}
+
+ {account.nextMessageAvailableAt && account.nextMessageAvailableAt > Date.now() && !account.invitationCooldown + ?

Next send: {formatTime(account.nextMessageAvailableAt)}

+ : null} + {account.invitationCooldown ? ( +
+ Cooldown until {formatTime(account.invitationCooldown.until)} + {account.invitationCooldown.reason} + {account.invitationCooldown.redditCode ? {account.invitationCooldown.redditCode} : null} +
+ ) : null} + {account.failure ?
Runtime failure{account.failure}
: null} +
+ ); + })} +
+ )} +
+ ); +} + function ActivityPanel({ stats }: { stats: BotStats }) { return (
@@ -166,7 +224,9 @@ export function OverviewPage() { {stats.invitationCooldown ? (
- Reddit invitation cooldown + {stats.invitationCooldown.redditUsername + ? `u/${stats.invitationCooldown.redditUsername} invitation cooldown` + : 'Reddit invitation cooldown'} {stats.invitationCooldown.reason}
) : null} @@ -178,6 +238,8 @@ export function OverviewPage() {
+ +
{timersQuery.isPending ? (
Loading sending window…
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 444ad76..8025e4c 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -76,6 +76,20 @@ h2 { margin-bottom: 0; font-size: 1.1rem; } .metric-card { display: grid; gap: .3rem; padding: 1rem; } .metric-card > span, .metric-card small { color: var(--muted); } .metric-card strong { font-size: 1.75rem; letter-spacing: -.04em; } +.account-status-panel { margin-bottom: .75rem; } +.account-status-grid { display: grid; gap: .75rem; } +.account-status-card { display: grid; gap: .75rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; } +.account-status-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; } +.account-status-card > p { margin: 0; color: var(--muted); font-size: .85rem; } +.account-status-details { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .45rem .9rem; margin: 0; } +.account-status-details div { display: grid; gap: .1rem; } +.account-status-details dt { color: var(--muted); font-size: .72rem; } +.account-status-details dd { margin: 0; font-weight: 700; text-transform: capitalize; } +.account-status-note { color: var(--muted); } +.account-status-warning { display: grid; gap: .25rem; padding: .65rem; color: #ffe1a0; background: #2a2111; border: 1px solid #59451e; border-radius: .55rem; } +.account-status-warning span { font-size: .8rem; } +.account-status-warning code { width: fit-content; padding: .15rem .3rem; color: inherit; background: rgba(0, 0, 0, .25); border-radius: .25rem; } +.account-status-warning.is-error { color: #ffd1ce; background: #461b18; border-color: #80352f; } .overview-grid { display: grid; gap: .75rem; } .panel { padding: 1rem; } .panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; } @@ -150,5 +164,6 @@ h2 { margin-bottom: 0; font-size: 1.1rem; } .page { padding: 3rem 2rem 5rem; } .navigation-backdrop { display: none; } .metrics-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } + .account-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .overview-grid { grid-template-columns: minmax(18rem, .8fr) minmax(22rem, 1.2fr); } } diff --git a/packages/api-contracts/src/stats.ts b/packages/api-contracts/src/stats.ts index d717117..580b446 100644 --- a/packages/api-contracts/src/stats.ts +++ b/packages/api-contracts/src/stats.ts @@ -1,6 +1,37 @@ import { z } from 'zod'; import { ActivityItemSchema } from './activity.js'; +export const AccountSendingStateSchema = z.enum([ + 'ready', + 'bot-stopped', + 'runtime-stopped', + 'runtime-failed', + 'login-required', + 'matrix-cooldown', + 'replies-prioritized', + 'pacing', +]); + +export const AccountStatusSchema = z.strictObject({ + accountId: z.string(), + redditUsername: z.string(), + runtimeState: z.enum(['created', 'starting', 'running', 'stopping', 'stopped', 'failed']), + authenticated: z.boolean(), + sendingState: AccountSendingStateSchema, + messagesThisHour: z.number(), + maxMessagesPerHour: z.number(), + nextMessageAvailableAt: z.number().nullable(), + hourlyResetAt: z.number(), + messagesRemaining: z.number(), + actionableReplies: z.number(), + invitationCooldown: z.strictObject({ + until: z.number(), + reason: z.string(), + redditCode: z.string().nullable(), + }).nullable(), + failure: z.string().nullable(), +}); + const PerformanceFields = { totalSent: z.number(), totalResponses: z.number(), @@ -27,10 +58,13 @@ export const StatsResponseSchema = z.strictObject({ })), targetGenders: z.array(z.string()), recentActivity: z.array(ActivityItemSchema), + accounts: z.array(AccountStatusSchema), invitationCooldown: z.strictObject({ until: z.number(), reason: z.string(), redditCode: z.string().nullable(), + accountId: z.string().optional(), + redditUsername: z.string().optional(), }).nullable(), }); @@ -93,5 +127,7 @@ export const AiUsageSummaryResponseSchema = z.strictObject({ }); export type StatsResponse = z.infer; +export type AccountStatus = z.infer; +export type AccountSendingState = z.infer; export type TimersResponse = z.infer; export type AiUsageSummaryResponse = z.infer; diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts index d153161..71c6350 100644 --- a/packages/api-contracts/tests/contracts.test.ts +++ b/packages/api-contracts/tests/contracts.test.ts @@ -58,6 +58,12 @@ describe('dashboard endpoint contracts', () => { subreddits: [{ name: 'example', type: 'image' }], targetGenders: ['female'], recentActivity: [activity], + accounts: [{ + accountId: '@t2_owner:reddit.com', redditUsername: 'owner', runtimeState: 'running', + authenticated: true, sendingState: 'ready', messagesThisHour: 2, maxMessagesPerHour: 8, + nextMessageAvailableAt: null, hourlyResetAt: 1_700_003_600_000, messagesRemaining: 6, + actionableReplies: 0, invitationCooldown: null, failure: null, + }], invitationCooldown: null, }; expect(StatsResponseSchema.parse(payload)).toEqual(payload); diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 20112ec..503a9a2 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -84,11 +84,15 @@ export function createConcreteRedditAccountRuntimeFactory( } async function buildRuntime( - { config, logger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions, + { config, logger: rootLogger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions, session: VerifiedAccountSession, recipientDeduplicator: RecipientDeduplicator, redditRequestCoordinator: RedditRequestCoordinator, ): Promise { + const logger = rootLogger.child({ + accountId: session.accountId, + redditUsername: session.redditUsername, + }); const browserProfileDir = accountBrowserProfileDir( path.resolve(process.cwd(), config.browser.sessionDir), session.accountId, diff --git a/src/web/routes/stats.ts b/src/web/routes/stats.ts index 59f0e01..821b10e 100644 --- a/src/web/routes/stats.ts +++ b/src/web/routes/stats.ts @@ -26,18 +26,64 @@ export function createStatsRouter(ctx: WebContext): Router { router.get('/stats', (_req, res) => { const runtimes = runningRuntimes(ctx); + const runtimeSnapshots = ctx.services.accountRuntimes?.list() ?? []; const messages = runtimes.length > 0 ? runtimes.flatMap((runtime) => runtime.resources.messagesRepo.getRecent(1000)) : ctx.repos.messages.getRecent(1000); const totalSent = messages.length; const totalResponses = messages.filter((m) => m.gotResponse).length; - const cooldown = runtimes.map((runtime) => runtime.resources.chatService.getInvitationCooldown()).find(Boolean) + const cooldownRuntime = runtimes.find((runtime) => runtime.resources.chatService.getInvitationCooldown()); + const cooldown = cooldownRuntime?.resources.chatService.getInvitationCooldown() ?? ctx.services.chat?.getInvitationCooldown() ?? null; const messagesThisHour = runtimes.length > 0 ? runtimes.reduce((total, runtime) => total + runtime.resources.rateLimiter.getStats().messagesThisHour, 0) : ctx.getMessagesThisHour(); + const accounts: BotStats['accounts'] = (ctx.services.redditAccounts?.list() ?? []).map((account) => { + const snapshot = runtimeSnapshots.find((item) => item.accountId === account.accountId); + const runtime = snapshot ? ctx.services.accountRuntimes?.get(snapshot.accountId) : null; + const authenticated = runtime?.resources.accountAuth.isFullyAuthenticated() ?? false; + const accountCooldown = runtime?.resources.chatService.getInvitationCooldown() ?? null; + const timers = runtime?.resources.rateLimiter.getTimers() ?? { + nextMessageAvailableAt: null, + hourlyResetAt: Date.now() + 60 * 60_000, + messagesRemaining: ctx.config.rateLimit.maxMessagesPerHour, + }; + const messageStats = runtime?.resources.rateLimiter.getStats(); + const priority = runtime?.resources.conversationAutomation.getPriorityStatus(); + const runtimeState = snapshot?.state ?? 'stopped'; + const sendingState: BotStats['accounts'][number]['sendingState'] = runtimeState === 'failed' + ? 'runtime-failed' + : account.status === 'login-required' || (runtimeState === 'running' && !authenticated) + ? 'login-required' + : runtimeState !== 'running' + ? 'runtime-stopped' + : accountCooldown + ? 'matrix-cooldown' + : !ctx.getBotRunning() + ? 'bot-stopped' + : (priority?.processing || (priority?.actionableBacklog ?? 0) > 0) + ? 'replies-prioritized' + : timers.nextMessageAvailableAt !== null && timers.nextMessageAvailableAt > Date.now() + ? 'pacing' + : 'ready'; + return { + accountId: account.accountId, + redditUsername: account.redditUsername, + runtimeState, + authenticated, + sendingState, + messagesThisHour: messageStats?.messagesThisHour ?? 0, + maxMessagesPerHour: ctx.config.rateLimit.maxMessagesPerHour, + nextMessageAvailableAt: timers.nextMessageAvailableAt, + hourlyResetAt: timers.hourlyResetAt, + messagesRemaining: timers.messagesRemaining, + actionableReplies: priority?.actionableBacklog ?? 0, + invitationCooldown: accountCooldown, + failure: snapshot?.failure ?? null, + }; + }); const stats: BotStats = { isRunning: ctx.getBotRunning(), @@ -53,8 +99,17 @@ export function createStatsRouter(ctx: WebContext): Router { subreddits: ctx.config.reddit.subreddits.map((s) => ({ name: s.name, type: s.type })), targetGenders: ctx.config.reddit.targetGenders, recentActivity: recentActivity(10), + accounts, invitationCooldown: cooldown - ? { until: cooldown.until, reason: cooldown.reason, redditCode: cooldown.redditCode } + ? { + until: cooldown.until, + reason: cooldown.reason, + redditCode: cooldown.redditCode, + ...(cooldownRuntime ? { + accountId: cooldownRuntime.session.accountId, + redditUsername: cooldownRuntime.session.redditUsername, + } : {}), + } : null, }; diff --git a/src/web/types.ts b/src/web/types.ts index 605561b..d280e5c 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -10,11 +10,28 @@ export interface BotStats { subreddits: { name: string; type: 'image' | 'chat' }[]; targetGenders: string[]; recentActivity: ActivityItem[]; + accounts: Array<{ + accountId: string; + redditUsername: string; + runtimeState: 'created' | 'starting' | 'running' | 'stopping' | 'stopped' | 'failed'; + authenticated: boolean; + sendingState: 'ready' | 'bot-stopped' | 'runtime-stopped' | 'runtime-failed' | 'login-required' | 'matrix-cooldown' | 'replies-prioritized' | 'pacing'; + messagesThisHour: number; + maxMessagesPerHour: number; + nextMessageAvailableAt: number | null; + hourlyResetAt: number; + messagesRemaining: number; + actionableReplies: number; + invitationCooldown: { until: number; reason: string; redditCode: string | null } | null; + failure: string | null; + }>; /** Non-null when Reddit has throttled new DM invitations. */ invitationCooldown: { until: number; reason: string; redditCode: string | null; + accountId?: string; + redditUsername?: string; } | null; } diff --git a/tests/dashboard/app.test.tsx b/tests/dashboard/app.test.tsx index 3176a92..f065815 100644 --- a/tests/dashboard/app.test.tsx +++ b/tests/dashboard/app.test.tsx @@ -13,6 +13,7 @@ import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js'; interface TestStatsOverrides { isRunning?: boolean; recentActivity?: readonly unknown[]; + accounts?: readonly unknown[]; } const server = setupServer(); @@ -30,6 +31,7 @@ function createStats(overrides: TestStatsOverrides = {}) { subreddits: [{ name: 'casualconversation', type: 'chat' }], targetGenders: ['female'], recentActivity: overrides.recentActivity ?? [], + accounts: overrides.accounts ?? [], invitationCooldown: null, }; } @@ -224,11 +226,51 @@ describe('dashboard foundation', () => { expect(screen.getByText('6 messages remaining')).toBeInTheDocument(); expect(screen.getByText('No recent activity yet.')).toBeInTheDocument(); expect(screen.getByText('Dry run')).toBeInTheDocument(); + expect(screen.getByText('No accounts configured.')).toBeInTheDocument(); expect(await screen.findByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('A recent opener'); expect(screen.getByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('Responded'); expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute('href', 'https://www.reddit.com/u/alice'); }); + it('shows the live sending status and cooldown for every account', async () => { + const now = Date.now(); + installSuccessfulApi({ + isRunning: true, + accounts: [ + { + accountId: '@t2_ready:reddit.com', redditUsername: 'ready-account', runtimeState: 'running', + authenticated: true, sendingState: 'ready', messagesThisHour: 2, maxMessagesPerHour: 8, + nextMessageAvailableAt: null, hourlyResetAt: now + 3_600_000, messagesRemaining: 6, + actionableReplies: 0, invitationCooldown: null, failure: null, + }, + { + accountId: '@t2_limited:reddit.com', redditUsername: 'limited-account', runtimeState: 'running', + authenticated: true, sendingState: 'matrix-cooldown', messagesThisHour: 5, maxMessagesPerHour: 8, + nextMessageAvailableAt: now + 600_000, hourlyResetAt: now + 3_600_000, messagesRemaining: 3, + actionableReplies: 1, + invitationCooldown: { + until: now + 600_000, + reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED', + redditCode: 'rate.invitation_limit_mature_account', + }, + failure: null, + }, + ], + }); + + renderDashboard(); + + const panel = await screen.findByRole('region', { name: 'Account status' }); + expect(within(panel).getByText('2 configured')).toBeInTheDocument(); + expect(within(panel).getByRole('article', { name: 'Status for u/ready-account' })).toHaveTextContent('Ready'); + const limited = within(panel).getByRole('article', { name: 'Status for u/limited-account' }); + expect(limited).toHaveTextContent('Matrix cooldown'); + expect(limited).toHaveTextContent('Matrix rate-limited: M_LIMIT_EXCEEDED'); + expect(limited).toHaveTextContent('rate.invitation_limit_mature_account'); + expect(limited).toHaveTextContent('5 of 8'); + expect(limited).toHaveTextContent('Pending replies1'); + }); + it('keeps stats visible when timers fail and retries only the failed panel', async () => { let timerRequests = 0; server.use( diff --git a/tests/dashboard/performance-operations.test.tsx b/tests/dashboard/performance-operations.test.tsx index 47c219d..6ff6197 100644 --- a/tests/dashboard/performance-operations.test.tsx +++ b/tests/dashboard/performance-operations.test.tsx @@ -132,7 +132,12 @@ describe('OperationsPage', () => { expect(new URL(request.url).searchParams.get('limit')).toBe('200'); return HttpResponse.json([ { timestamp: Date.parse('2026-08-08T12:00:01Z'), level: 'info', message: 'Sync complete' }, - { timestamp: Date.parse('2026-08-08T12:00:02Z'), level: 'error', message: 'Matrix request failed' }, + { + timestamp: Date.parse('2026-08-08T12:00:02Z'), + level: 'error', + message: 'Matrix request failed: M_LIMIT_EXCEEDED', + data: { accountId: '@t2_owner:reddit.com', redditUsername: 'operator' }, + }, ]); }), http.get('/api/chats/sync', () => @@ -171,10 +176,14 @@ describe('OperationsPage', () => { const consoleRegion = screen.getByRole('region', { name: 'Live console' }); expect(consoleRegion).toHaveTextContent('Sync complete'); - expect(consoleRegion).toHaveTextContent('Matrix request failed'); + expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED'); + expect(within(consoleRegion).getByRole('link', { name: 'u/operator' })).toHaveAttribute( + 'href', + 'https://www.reddit.com/u/operator', + ); await user.selectOptions(within(consoleRegion).getByLabelText('Log level'), 'error'); expect(consoleRegion).not.toHaveTextContent('Sync complete'); - expect(consoleRegion).toHaveTextContent('Matrix request failed'); + expect(consoleRegion).toHaveTextContent('Matrix request failed: M_LIMIT_EXCEEDED'); await user.type(within(consoleRegion).getByLabelText('Search logs'), 'no match'); expect(consoleRegion).toHaveTextContent('No logs match the current filters.'); expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false'); diff --git a/tests/log-broadcaster.test.ts b/tests/log-broadcaster.test.ts index f7fd448..f3de2d0 100644 --- a/tests/log-broadcaster.test.ts +++ b/tests/log-broadcaster.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; +import winston from 'winston'; import { RealtimeEventHub, type RealtimeEvent } from '../src/web/events/index.js'; import { LogBroadcaster } from '../src/web/log-broadcaster.js'; +import { attachLogSink } from '../src/utils/logger.js'; describe('LogBroadcaster realtime bridge', () => { it('publishes each log through the typed event hub', () => { @@ -67,4 +69,27 @@ describe('LogBroadcaster realtime bridge', () => { expect(serializedRecent).not.toContain('must-not-leak'); expect(serializedRecent).not.toContain('"request"'); }); + + it('preserves account metadata from a child logger', async () => { + const broadcaster = new LogBroadcaster(); + const logger = winston.createLogger({ level: 'debug' }); + attachLogSink(logger, broadcaster); + + logger.child({ + accountId: '@t2_owner:reddit.com', + redditUsername: 'operator', + }).warn('Matrix request failed: M_LIMIT_EXCEEDED'); + await new Promise((resolve) => setImmediate(resolve)); + + expect(broadcaster.getRecent()).toEqual([ + expect.objectContaining({ + level: 'warn', + message: 'Matrix request failed: M_LIMIT_EXCEEDED', + data: { + accountId: '@t2_owner:reddit.com', + redditUsername: 'operator', + }, + }), + ]); + }); }); diff --git a/tests/web/stats-route.test.ts b/tests/web/stats-route.test.ts new file mode 100644 index 0000000..b3cffd0 --- /dev/null +++ b/tests/web/stats-route.test.ts @@ -0,0 +1,94 @@ +import express from 'express'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it } from 'vitest'; +import { StatsResponseSchema } from '../../packages/api-contracts/src/index.js'; +import type { WebContext } from '../../src/web/context.js'; +import { createStatsRouter } from '../../src/web/routes/stats.js'; + +const servers: Array> = []; + +async function request(app: express.Express, path: string): Promise { + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + return fetch(`http://127.0.0.1:${port}${path}`); +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(() => resolve())))); +}); + +describe('stats route account status', () => { + it('reports sending availability and Matrix cooldown independently for every account', async () => { + const now = Date.now(); + const account = ( + accountId: string, + redditUsername: string, + cooldown: { until: number; reason: string; redditCode: string | null } | null, + ) => ({ + session: { accountId, redditUsername }, + resources: { + accountAuth: { isFullyAuthenticated: () => true }, + chatService: { getInvitationCooldown: () => cooldown }, + rateLimiter: { + getStats: () => ({ messagesThisHour: cooldown ? 5 : 2, minutesSinceLastMessage: 1 }), + getTimers: () => ({ + nextMessageAvailableAt: cooldown?.until ?? null, + hourlyResetAt: now + 3_600_000, + isHourlyLimitReached: false, + messagesRemaining: cooldown ? 3 : 6, + }), + }, + conversationAutomation: { getPriorityStatus: () => ({ processing: false, actionableBacklog: 0 }) }, + messagesRepo: { getRecent: () => [] }, + activityLogRepo: { getRecent: () => [], countSentSkipped: () => 0 }, + }, + }); + const ready = account('@t2_ready:reddit.com', 'ready-account', null); + const limited = account('@t2_limited:reddit.com', 'limited-account', { + until: now + 600_000, + reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED', + redditCode: 'rate.invitation_limit_mature_account', + }); + const runtimes = new Map([[ready.session.accountId, ready], [limited.session.accountId, limited]]); + const context = { + config: { + browser: { dryRun: false }, + rateLimit: { maxMessagesPerHour: 8 }, + reddit: { subreddits: [], targetGenders: [] }, + }, + services: { + redditAccounts: { list: () => [...runtimes.values()].map((runtime) => runtime.session) }, + accountRuntimes: { + list: () => [...runtimes.values()].map((runtime) => ({ + ...runtime.session, + matrixUserId: runtime.session.accountId, + state: 'running', + updatedAt: now, + failure: null, + })), + get: (accountId: string) => runtimes.get(accountId) ?? null, + }, + }, + repos: { messages: { getRecent: () => [] } }, + activity: { recent: () => [], totalProcessed: () => 0 }, + getBotRunning: () => true, + getMessagesThisHour: () => 0, + } as unknown as WebContext; + const app = express().use('/api', createStatsRouter(context)); + + const response = await request(app, '/api/stats'); + const stats = StatsResponseSchema.parse(await response.json()); + + expect(stats.accounts).toEqual([ + expect.objectContaining({ redditUsername: 'ready-account', sendingState: 'ready', messagesThisHour: 2 }), + expect.objectContaining({ + redditUsername: 'limited-account', + sendingState: 'matrix-cooldown', + invitationCooldown: expect.objectContaining({ reason: 'Matrix rate-limited: M_LIMIT_EXCEEDED' }), + }), + ]); + expect(stats.invitationCooldown).toMatchObject({ redditUsername: 'limited-account' }); + }); +});