diff --git a/apps/dashboard/src/api/client.ts b/apps/dashboard/src/api/client.ts index 15e1796..5a2107b 100644 --- a/apps/dashboard/src/api/client.ts +++ b/apps/dashboard/src/api/client.ts @@ -3,6 +3,7 @@ import { botStatsSchema, controlResponseSchema, timersSchema, + type BotMode, type BotStats, type ControlResponse, type Timers, @@ -36,7 +37,7 @@ async function parseResponse(response: Response, schema: ZodType): Promise export interface DashboardApi { getStats(): Promise; getTimers(): Promise; - setRunning(isRunning: boolean): Promise; + setMode(mode: BotMode): Promise; } export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi { @@ -49,9 +50,15 @@ export function createDashboardApi(fetchImplementation?: typeof fetch): Dashboar async getTimers() { return parseResponse(await request('/api/timers'), timersSchema); }, - async setRunning(isRunning) { - const endpoint = isRunning ? '/api/start' : '/api/stop'; - return parseResponse(await request(endpoint, { method: 'POST' }), controlResponseSchema); + async setMode(mode) { + return parseResponse( + await request('/api/mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }), + }), + controlResponseSchema, + ); }, }; } diff --git a/apps/dashboard/src/api/contracts.ts b/apps/dashboard/src/api/contracts.ts index 7c02874..7fa3dc8 100644 --- a/apps/dashboard/src/api/contracts.ts +++ b/apps/dashboard/src/api/contracts.ts @@ -5,6 +5,7 @@ export { } from '../../../../packages/api-contracts/src/index.js'; export type { + BotMode, ControlResponse, StatsResponse as BotStats, TimersResponse as Timers, diff --git a/apps/dashboard/src/api/queries.ts b/apps/dashboard/src/api/queries.ts index 5893e95..f3931ef 100644 --- a/apps/dashboard/src/api/queries.ts +++ b/apps/dashboard/src/api/queries.ts @@ -19,13 +19,13 @@ export function useTimersQuery() { return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers, refetchInterval: 15_000 }); } -export function useSetRunningMutation() { +export function useSetModeMutation() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: dashboardApi.setRunning, + mutationFn: dashboardApi.setMode, onSuccess(response) { queryClient.setQueryData(dashboardQueryKeys.stats, (current) => - current ? { ...current, isRunning: response.running } : current, + current ? { ...current, isRunning: response.running, mode: response.mode } : current, ); }, }); diff --git a/apps/dashboard/src/app/shell-status-panel.tsx b/apps/dashboard/src/app/shell-status-panel.tsx index dbb742f..9b178a8 100644 --- a/apps/dashboard/src/app/shell-status-panel.tsx +++ b/apps/dashboard/src/app/shell-status-panel.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { useStatsQuery, useSetRunningMutation } from '../api/queries.js'; +import { useStatsQuery, useSetModeMutation } from '../api/queries.js'; +import type { BotMode } from '../api/contracts.js'; import { ConfirmationDialog } from '../components/confirmation-dialog.js'; import { useSyncStatusQuery } from '../features/operations/operations-queries.js'; import { useLoginStatusQuery } from '../features/settings/settings-queries.js'; @@ -12,15 +13,15 @@ export function ShellStatusPanel() { const reddit = useLoginStatusQuery(); const sync = useSyncStatusQuery(); const realtime = useDashboardRealtime(); - const control = useSetRunningMutation(); - const [confirming, setConfirming] = useState<'start' | 'stop' | null>(null); + const control = useSetModeMutation(); + const [confirming, setConfirming] = useState(null); const status = deriveShellStatus({ stats: stats.data, reddit: reddit.data, sync: sync.data, realtime: realtime.connection }); return <>
{status.bot}{status.reddit}{status.realtime}{status.sync} {status.cooldown ? Cooldown: {status.cooldown} : null} - +
- {confirming ? setConfirming(null)} onConfirm={() => control.mutate(confirming === 'start', { onSuccess: () => setConfirming(null) })} /> : null} + {confirming ? setConfirming(null)} onConfirm={() => control.mutate(confirming, { onSuccess: () => setConfirming(null) })} /> : null} ; } diff --git a/apps/dashboard/src/app/shell-status.ts b/apps/dashboard/src/app/shell-status.ts index 78218a2..8823be9 100644 --- a/apps/dashboard/src/app/shell-status.ts +++ b/apps/dashboard/src/app/shell-status.ts @@ -1,7 +1,14 @@ import type { RealtimeConnectionSnapshot } from '../features/operations/realtime-client.js'; +import type { BotMode } from '../api/contracts.js'; + +const MODE_LABEL: Record = { + running: 'Running', + scan: 'Scanning', + stopped: 'Stopped', +}; interface ShellStatusInput { - stats?: { isRunning: boolean; dryRunMode: boolean; invitationCooldown: { reason: string } | null }; + stats?: { mode: BotMode; dryRunMode: boolean; invitationCooldown: { reason: string } | null }; reddit?: { ready: boolean; loggedIn?: boolean; status?: string; error?: string }; realtime: RealtimeConnectionSnapshot; sync?: { syncing: boolean; syncingKind?: string | null }; @@ -13,7 +20,7 @@ export interface ShellStatus { realtime: string; cooldown: string | null; sync: string; - controlAction: 'start' | 'stop'; + controlAction: BotMode; controlDisabled: boolean; } @@ -28,12 +35,14 @@ export function deriveShellStatus(input: ShellStatusInput): ShellStatus { ? input.reddit.loggedIn ? `Reddit ${input.reddit.status ?? 'authenticated'}` : 'Reddit login required' : 'Reddit session unavailable'; return { - bot: stats ? `${stats.isRunning ? 'Running' : 'Stopped'}${stats.dryRunMode ? ' (dry run)' : ''}` : 'Bot state unavailable', + bot: stats ? `${MODE_LABEL[stats.mode]}${stats.dryRunMode ? ' (dry run)' : ''}` : 'Bot state unavailable', reddit, realtime, cooldown: stats?.invitationCooldown?.reason ?? null, sync: input.sync?.syncing ? `${sentence(input.sync.syncingKind ?? 'unknown')} sync active` : 'No active sync', - controlAction: stats?.isRunning ? 'stop' : 'start', + // The header's single control is the opposite of wherever we are now; + // scan mode's natural next step is a full start. + controlAction: stats?.mode === 'running' ? 'stopped' : 'running', controlDisabled: stats === undefined, }; } diff --git a/apps/dashboard/src/components/confirmation-dialog.tsx b/apps/dashboard/src/components/confirmation-dialog.tsx index 60fcbdb..fb3dd55 100644 --- a/apps/dashboard/src/components/confirmation-dialog.tsx +++ b/apps/dashboard/src/components/confirmation-dialog.tsx @@ -1,32 +1,50 @@ +import type { BotMode } from '../api/contracts.js'; import { Modal } from './modal.js'; interface ConfirmationDialogProps { - action: 'start' | 'stop'; + action: BotMode; errorMessage?: string | null; isPending: boolean; onCancel(): void; onConfirm(): void; } +const copy: Record = { + running: { + title: 'Start bot?', + body: 'The bot will resume automated processing.', + confirm: 'Confirm start', + tone: 'button primary', + }, + scan: { + title: 'Switch to scan mode?', + body: 'Conversations stay in sync and replies are flagged for you, but nothing is sent and no new candidates are discovered.', + confirm: 'Confirm scan mode', + tone: 'button secondary', + }, + stopped: { + title: 'Stop bot?', + body: 'The bot will pause after its current operation. Conversations stop syncing too — use scan mode to keep them up to date.', + confirm: 'Confirm stop', + tone: 'button danger', + }, +}; + export function ConfirmationDialog({ action, errorMessage = null, isPending, onCancel, onConfirm }: ConfirmationDialogProps) { - const isStarting = action === 'start'; + const { title, body, confirm, tone } = copy[action]; return ( Confirm action -

{isStarting ? 'Start bot?' : 'Stop bot?'}

-

- {isStarting - ? 'The bot will resume automated processing.' - : 'The bot will pause after its current operation.'} -

+

{title}

+

{body}

{errorMessage ?

{errorMessage}

: null}
-
diff --git a/apps/dashboard/src/features/overview/overview-page.tsx b/apps/dashboard/src/features/overview/overview-page.tsx index d09e4f6..95d1caa 100644 --- a/apps/dashboard/src/features/overview/overview-page.tsx +++ b/apps/dashboard/src/features/overview/overview-page.tsx @@ -1,7 +1,7 @@ import { useEffect, 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 { dashboardQueryKeys, useSetModeMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js'; +import type { BotMode, 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'; @@ -9,6 +9,14 @@ import { OriginPostButton } from '../../components/origin-post.js'; import { RecentMessagesPanel } from './recent-messages.js'; import { AccountOwnerBadge } from '../../components/account-owner-badge.js'; +const MODE_ORDER: readonly BotMode[] = ['running', 'scan', 'stopped']; + +const modeCopy: Record = { + running: { label: 'Running', action: 'Start bot', dot: 'running', buttonTone: 'button primary' }, + scan: { label: 'Scanning', action: 'Scan mode', dot: 'scanning', buttonTone: 'button secondary' }, + stopped: { label: 'Stopped', action: 'Stop bot', dot: 'stopped', buttonTone: 'button danger' }, +}; + function errorMessage(error: Error | null): string { return error?.message ?? 'Something went wrong.'; } @@ -83,6 +91,7 @@ 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.' }, + 'scan-only': { label: 'Scan only', tone: 'neutral', detail: 'Conversations stay in sync, but nothing is sent.' }, '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.' }, @@ -174,8 +183,8 @@ export function OverviewPage() { const queryClient = useQueryClient(); const statsQuery = useStatsQuery(); const timersQuery = useTimersQuery(); - const controlMutation = useSetRunningMutation(); - const [confirmationAction, setConfirmationAction] = useState<'start' | 'stop' | null>(null); + const controlMutation = useSetModeMutation(); + const [confirmationAction, setConfirmationAction] = useState(null); const isRefreshing = (statsQuery.isFetching || timersQuery.isFetching) && !statsQuery.isPending; async function refreshOverview() { @@ -187,7 +196,7 @@ export function OverviewPage() { function confirmControl() { if (!confirmationAction) return; - controlMutation.mutate(confirmationAction === 'start', { + controlMutation.mutate(confirmationAction, { onSuccess: () => setConfirmationAction(null), }); } @@ -216,7 +225,8 @@ export function OverviewPage() { } const stats = statsQuery.data; - const action = stats.isRunning ? 'stop' : 'start'; + // Offer the two modes the bot is not currently in, in a stable order. + const offeredModes = MODE_ORDER.filter((mode) => mode !== stats.mode); return (
@@ -237,20 +247,23 @@ export function OverviewPage() { > {isRefreshing ? 'Refreshing…' : 'Refresh'} - + {offeredModes.map((mode) => ( + + ))}
-
{stats.dryRunMode ? Dry run : null} {stats.messagesThisHour} of {stats.maxMessagesPerHour} messages used this hour diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index fc6c96a..256162b 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -77,6 +77,7 @@ h2 { margin-bottom: 0; font-size: 1.1rem; } .status-strip small { color: var(--muted); } .status-dot { width: .7rem; height: .7rem; border-radius: 50%; } .status-dot.running { background: var(--accent); box-shadow: 0 0 0 .35rem rgba(117, 214, 157, .12); } +.status-dot.scanning { background: var(--warning-text); box-shadow: 0 0 0 .35rem rgba(255, 200, 87, .12); } .status-dot.stopped { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); } .pill { display: inline-flex; align-items: center; padding: .3rem .55rem; border-radius: 999px; font-size: .75rem; font-weight: 800; } .pill.neutral { color: #d4dfda; background: #2b3832; } diff --git a/docs/api.md b/docs/api.md index 209d236..ef831d6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -55,6 +55,7 @@ an administrator credential. | `GET` | `/api/v1/control` | Get bot running state | read | no | | `POST` | `/api/v1/control` | Set bot running state | control | yes | | `POST` | `/api/v1/control/start` | Start bot processing | control | yes | +| `POST` | `/api/v1/control/scan` | Keep conversations in sync without sending | control | yes | | `POST` | `/api/v1/control/stop` | Stop bot processing | control | yes | | `GET` | `/api/v1/jobs` | List recent asynchronous jobs | read | no | | `GET` | `/api/v1/jobs/{id}` | Get asynchronous job state | read | no | @@ -93,7 +94,9 @@ an administrator credential. | `PATCH` | `/api/media-library/{id}` | Correct tags or enable an exchange image | admin | yes | | `DELETE` | `/api/media-library/{id}` | Delete an exchange image | admin | yes | | `POST` | `/api/start` | Start bot processing (legacy) | admin | yes | +| `POST` | `/api/scan` | Switch to scan mode (legacy) | admin | yes | | `POST` | `/api/stop` | Stop bot processing (legacy) | admin | yes | +| `POST` | `/api/mode` | Set bot mode: running, scan or stopped (legacy) | admin | yes | | `GET` | `/api/timers` | Get rate-limit timers | read | no | | `GET` | `/api/analytics/by-subreddit` | Get performance by subreddit | read | no | | `GET` | `/api/analytics/by-strategy` | Get performance by strategy | read | no | diff --git a/packages/api-contracts/src/common.ts b/packages/api-contracts/src/common.ts index db7d81e..9cdc131 100644 --- a/packages/api-contracts/src/common.ts +++ b/packages/api-contracts/src/common.ts @@ -6,9 +6,11 @@ export const ErrorResponseSchema = z.strictObject({ }); export const SuccessResponseSchema = z.strictObject({ success: z.literal(true) }); +export const BotModeSchema = z.enum(['running', 'scan', 'stopped']); export const ControlResponseSchema = z.strictObject({ success: z.literal(true), running: z.boolean(), + mode: BotModeSchema, }); export const RejectedActionResponseSchema = z.strictObject({ success: z.literal(false), @@ -17,4 +19,5 @@ export const RejectedActionResponseSchema = z.strictObject({ export type ErrorResponse = z.infer; export type SuccessResponse = z.infer; +export type BotMode = z.infer; export type ControlResponse = z.infer; diff --git a/packages/api-contracts/src/stats.ts b/packages/api-contracts/src/stats.ts index 31f9bbf..4e50b2f 100644 --- a/packages/api-contracts/src/stats.ts +++ b/packages/api-contracts/src/stats.ts @@ -1,9 +1,11 @@ import { z } from 'zod'; import { ActivityItemSchema } from './activity.js'; +import { BotModeSchema } from './common.js'; export const AccountSendingStateSchema = z.enum([ 'ready', 'bot-stopped', + 'scan-only', 'runtime-stopped', 'runtime-failed', 'login-required', @@ -46,6 +48,7 @@ const PerformanceFields = { export const StatsResponseSchema = z.strictObject({ isRunning: z.boolean(), + mode: BotModeSchema, dryRunMode: z.boolean(), totalProcessed: z.number(), totalMessagesSent: z.number(), diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts index 66cf944..4916579 100644 --- a/packages/api-contracts/tests/contracts.test.ts +++ b/packages/api-contracts/tests/contracts.test.ts @@ -49,6 +49,7 @@ describe('dashboard endpoint contracts', () => { it('accepts current stats and rejects malformed stats', () => { const payload = { isRunning: true, + mode: 'running', dryRunMode: false, totalProcessed: 10, totalMessagesSent: 8, @@ -71,11 +72,14 @@ describe('dashboard endpoint contracts', () => { expect(StatsResponseSchema.safeParse({ ...payload, isRunning: 'yes' }).success).toBe(false); }); - it('validates authoritative start and stop responses', () => { - expect(ControlResponseSchema.safeParse({ success: true, running: true }).success).toBe(true); - expect(ControlResponseSchema.safeParse({ success: true, running: false }).success).toBe(true); + it('validates authoritative start, scan and stop responses', () => { + expect(ControlResponseSchema.safeParse({ success: true, running: true, mode: 'running' }).success).toBe(true); + expect(ControlResponseSchema.safeParse({ success: true, running: false, mode: 'scan' }).success).toBe(true); + expect(ControlResponseSchema.safeParse({ success: true, running: false, mode: 'stopped' }).success).toBe(true); + expect(ControlResponseSchema.safeParse({ success: true, running: true }).success).toBe(false); + expect(ControlResponseSchema.safeParse({ success: true, running: false, mode: 'paused' }).success).toBe(false); expect(ControlResponseSchema.safeParse({ success: true }).success).toBe(false); - expect(ControlResponseSchema.safeParse({ success: false, running: true }).success).toBe(false); + expect(ControlResponseSchema.safeParse({ success: false, running: true, mode: 'running' }).success).toBe(false); }); it('validates timer, activity, message, and successful-example payloads', () => { diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 492525d..0725c9d 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -366,7 +366,7 @@ export async function bootstrap( config, logger, db, - isBotRunning: () => webServer?.isRunning() ?? true, + getBotMode: () => webServer?.getMode() ?? 'running', }), ); const redditAccounts = new RedditAccountsService( @@ -389,8 +389,8 @@ export async function bootstrap( broadcaster, process.cwd(), aiUsageRepo, - runtimeStateRepo.isBotRunning(), - (running) => runtimeStateRepo.setBotRunning(running), + runtimeStateRepo.getBotMode(), + (mode) => runtimeStateRepo.setBotMode(mode), accountContentRepo, ); diff --git a/src/database/candidate-queue-repository.ts b/src/database/candidate-queue-repository.ts index 7fdd21d..5862e48 100644 --- a/src/database/candidate-queue-repository.ts +++ b/src/database/candidate-queue-repository.ts @@ -1,6 +1,7 @@ import type Database from 'better-sqlite3'; import type { PhysiqueTargeting } from '../config/index.js'; import type { RedditPost } from '../reddit/api.js'; +import { allowsSending, parseBotMode, type BotMode } from '../services/bot-mode.js'; import { evaluatePhysiqueTargeting } from '../services/physique-targeting.js'; import type { ProcessedPost } from '../services/processor.js'; @@ -429,20 +430,33 @@ export class CandidateQueueRepository { } } -/** Persists the operator's intended pause/run state across container restarts. */ +/** Persists the operator's intended run/scan/stop state across container restarts. */ export class RuntimeStateRepository { constructor(private readonly db: Database.Database) {} + getBotMode(): BotMode { + const row = this.db.prepare('SELECT bot_running, bot_mode FROM runtime_state WHERE id = 1') + .get() as { bot_running: number; bot_mode: string | null } | undefined; + if (!row) return 'stopped'; + // `bot_mode` is null for rows written by an older build; fall back to the boolean. + return parseBotMode(row.bot_mode, row.bot_running === 1 ? 'running' : 'stopped'); + } + + setBotMode(mode: BotMode, now: number = Date.now()): void { + this.db.prepare(` + INSERT INTO runtime_state (id, bot_running, bot_mode, updated_at) VALUES (1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + bot_running = excluded.bot_running, + bot_mode = excluded.bot_mode, + updated_at = excluded.updated_at + `).run(allowsSending(mode) ? 1 : 0, mode, now); + } + isBotRunning(): boolean { - const row = this.db.prepare('SELECT bot_running FROM runtime_state WHERE id = 1') - .get() as { bot_running: number } | undefined; - return row?.bot_running === 1; + return allowsSending(this.getBotMode()); } setBotRunning(running: boolean, now: number = Date.now()): void { - this.db.prepare(` - INSERT INTO runtime_state (id, bot_running, updated_at) VALUES (1, ?, ?) - ON CONFLICT(id) DO UPDATE SET bot_running = excluded.bot_running, updated_at = excluded.updated_at - `).run(running ? 1 : 0, now); + this.setBotMode(running ? 'running' : 'stopped', now); } } diff --git a/src/database/client.ts b/src/database/client.ts index d3f7cde..37ff4b2 100644 --- a/src/database/client.ts +++ b/src/database/client.ts @@ -477,6 +477,13 @@ export function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_reddit_account_establishment_state ON reddit_account_establishment(state, retry_at); `), + () => { + const columns = db.prepare(`PRAGMA table_info(runtime_state)`).all() as { name: string }[]; + if (columns.some((column) => column.name === 'bot_mode')) return; + db.exec(`ALTER TABLE runtime_state ADD COLUMN bot_mode TEXT`); + // Existing rows only knew running/stopped; preserve that intent verbatim. + db.exec(`UPDATE runtime_state SET bot_mode = CASE WHEN bot_running = 1 THEN 'running' ELSE 'stopped' END`); + }, ]; let currentVersion = db.pragma('user_version', { simple: true }) as number; diff --git a/src/main-loop.ts b/src/main-loop.ts index be4a0a8..52ecdd5 100644 --- a/src/main-loop.ts +++ b/src/main-loop.ts @@ -4,6 +4,7 @@ import { isSkip } from './services/processor.js'; import { reportDeliveryResult, reportInvitationCooldown } from './web/event-reporting.js'; import { SessionBoundRuntime } from './services/session-bound-runtime.js'; import { RedditRateLimitError } from './reddit/api.js'; +import { allowsConversationSync, allowsDiscovery, allowsSending } from './services/bot-mode.js'; export interface StoppableSchedule { stop(): void; @@ -208,20 +209,25 @@ export async function runMainLoop(app: AppContainer): Promise { while (!isShuttingDown) { try { + const mode = webServer?.getMode() ?? 'running'; const authenticated = accountAuth.isFullyAuthenticated() || await accountAuth.ensureFullyAuthenticated(); - if (!sessionRuntime.reconcile(authenticated)) { + // Response checking, media sync and the startup chat sync live behind + // this reconcile, so scan mode keeps them alive while `stopped` does not. + if (!sessionRuntime.reconcile(authenticated && allowsConversationSync(mode))) { if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) { - logger.info('Reddit is logged out. Waiting for a session dump from the extension.'); + logger.info(authenticated + ? 'Bot is stopped. Use the web dashboard to start or switch to scan mode.' + : 'Reddit is logged out. Waiting for a session dump from the extension.'); lastPausedLog = Date.now(); } await sleep(PAUSE_POLL_MS, shutdownController.signal); continue; } - if (webServer && !webServer.isRunning()) { + if (!allowsSending(mode)) { if (Date.now() - lastPausedLog > PAUSED_LOG_INTERVAL_MS) { - logger.info('Bot is paused. Use the web dashboard to start.'); + logger.info('Bot is in scan mode — conversations stay in sync, no new outreach.'); lastPausedLog = Date.now(); } await sleep(PAUSE_POLL_MS, shutdownController.signal); @@ -518,7 +524,7 @@ async function runConcurrentAccountRuntimes(app: AppContainer): Promise { const discoveryLoop = (async (): Promise => { while (!controller.signal.aborted) { try { - if (!(webServer?.isRunning() ?? true)) { + if (!allowsDiscovery(webServer?.getMode() ?? 'running')) { await sleep(1_000, controller.signal).catch(() => undefined); continue; } @@ -571,7 +577,7 @@ async function runConcurrentAccountRuntimes(app: AppContainer): Promise { const screeningLoop = (async (): Promise => { while (!controller.signal.aborted) { - if (!(webServer?.isRunning() ?? true)) { + if (!allowsDiscovery(webServer?.getMode() ?? 'running')) { await sleep(1_000, controller.signal).catch(() => undefined); continue; } @@ -638,7 +644,7 @@ async function runConcurrentAccountRuntimes(app: AppContainer): Promise { const dispatchLoop = (async (): Promise => { while (!controller.signal.aborted) { - if (!(webServer?.isRunning() ?? true)) { + if (!allowsDiscovery(webServer?.getMode() ?? 'running')) { await sleep(1_000, controller.signal).catch(() => undefined); continue; } diff --git a/src/services/bot-mode.ts b/src/services/bot-mode.ts new file mode 100644 index 0000000..82952a6 --- /dev/null +++ b/src/services/bot-mode.ts @@ -0,0 +1,48 @@ +/** + * The operator's intended runtime posture. + * + * - `running` — discover candidates, screen them, send outbound messages and + * automate replies. + * - `scan` — keep conversations in sync (chat refresh, reply detection, + * response checking) but send nothing and start no new outreach. Actionable + * replies are surfaced as backlog for a human instead of being answered. + * - `stopped` — everything halts. + * + * `scan` differs from `browser.dryRun`: dry run still discovers and screens + * candidates (and pays for the AI screening), it just suppresses the send. + */ +export type BotMode = 'running' | 'scan' | 'stopped'; + +export const BOT_MODES: readonly BotMode[] = ['running', 'scan', 'stopped']; + +export function isBotMode(value: unknown): value is BotMode { + return typeof value === 'string' && (BOT_MODES as readonly string[]).includes(value); +} + +/** Falls back to `stopped` so an unreadable persisted value never auto-sends. */ +export function parseBotMode(value: unknown, fallback: BotMode = 'stopped'): BotMode { + return isBotMode(value) ? value : fallback; +} + +/** Outbound messages and automated replies. */ +export function allowsSending(mode: BotMode): boolean { + return mode === 'running'; +} + +/** Subreddit scanning, AI screening and candidate dispatch. */ +export function allowsDiscovery(mode: BotMode): boolean { + return mode === 'running'; +} + +/** Chat refresh, reply detection and response checking. */ +export function allowsConversationSync(mode: BotMode): boolean { + return mode === 'running' || mode === 'scan'; +} + +export function describeBotMode(mode: BotMode): string { + switch (mode) { + case 'running': return 'Running'; + case 'scan': return 'Scanning'; + case 'stopped': return 'Stopped'; + } +} diff --git a/src/services/conversation-automation.ts b/src/services/conversation-automation.ts index c16b611..d73f16a 100644 --- a/src/services/conversation-automation.ts +++ b/src/services/conversation-automation.ts @@ -140,7 +140,16 @@ export class ConversationAutomationService { /** In-process memo so a claim retry does not pay for the same verdict twice. */ private readonly screeningCache = new Map(); - async processPendingConversations(signal?: AbortSignal): Promise { + /** + * Refreshes chats, recomputes the actionable backlog, and (unless + * `scanOnly`) generates and sends replies. Scan mode stops after the + * bookkeeping so the backlog surfaces for a human without spending AI + * tokens or touching Reddit. + */ + async processPendingConversations( + signal?: AbortSignal, + { scanOnly = false }: { scanOnly?: boolean } = {}, + ): Promise { this.processing = true; let conversations: ActiveChat[] = []; try { @@ -161,6 +170,15 @@ export class ConversationAutomationService { ); } + if (scanOnly) { + if (actionable.length > 0) { + this.logger.info( + `[automation] Scan mode: leaving ${actionable.length} actionable conversation(s) for a human`, + ); + } + return []; + } + const results: ConversationAutomationResult[] = []; for (const conversation of actionable) { const result = await this.processConversation(conversation, signal); diff --git a/src/services/reddit-account-runtime-builder.ts b/src/services/reddit-account-runtime-builder.ts index 2017bd1..8c760e3 100644 --- a/src/services/reddit-account-runtime-builder.ts +++ b/src/services/reddit-account-runtime-builder.ts @@ -55,12 +55,13 @@ import { MediaLibraryRepository } from '../database/media-library-repository.js' import { MediaLibraryExchangeService } from './media-library-exchange.js'; import { RecipientAccountStatusService } from './recipient-account-status.js'; import { AccountEstablishmentService } from './account-establishment.js'; +import { allowsConversationSync, allowsSending, type BotMode } from './bot-mode.js'; export interface ConcreteRuntimeFactoryOptions { config: Config; logger: Logger; db: Database.Database; - isBotRunning?: () => boolean; + getBotMode?: () => BotMode; } export function materializePreparedPostForAccount( @@ -95,7 +96,7 @@ export function createConcreteRedditAccountRuntimeFactory( } async function buildRuntime( - { config, logger: rootLogger, db, isBotRunning = () => true }: ConcreteRuntimeFactoryOptions, + { config, logger: rootLogger, db, getBotMode = () => 'running' }: ConcreteRuntimeFactoryOptions, session: VerifiedAccountSession, recipientDeduplicator: RecipientDeduplicator, redditRequestCoordinator: RedditRequestCoordinator, @@ -291,7 +292,7 @@ async function buildRuntime( recipientAccountStatusService, establishmentService, }; const lifecycle = new AccountRuntimeLoop( - resources, accountConfig, logger, isBotRunning, recipientDeduplicator, + resources, accountConfig, logger, getBotMode, recipientDeduplicator, ); return new RedditAccountRuntime(session, resources, lifecycle); } catch (error) { @@ -312,32 +313,32 @@ export function runtimeAuthenticatedAccount( }; } -class AccountRuntimeLoop { +/** Exported for tests; production code reaches it through the runtime factory. */ +export class AccountRuntimeLoop { private controller: AbortController | null = null; private loop: Promise | null = null; + private mediaSyncRunning = false; + private startupSyncStarted = false; constructor( private readonly resources: RedditAccountRuntimeResources, private readonly config: Config, private readonly logger: Logger, - private readonly isBotRunning: () => boolean, + private readonly getBotMode: () => BotMode, private readonly recipientDeduplicator: RecipientDeduplicator, ) {} start(): void { if (this.loop) return; this.controller = new AbortController(); - this.resources.mediaSyncService.start(); this.resources.recipientAccountStatusService.start(); this.resources.establishmentService.start(); - void this.resources.chatService.runStartupSync() - .then(() => this.resources.chatService.enrichUsernames()) - .catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error)); this.loop = this.run(this.controller.signal).finally(() => { this.loop = null; }); } async stop(): Promise { this.controller?.abort(); + this.mediaSyncRunning = false; this.resources.mediaSyncService.stop(); this.resources.recipientAccountStatusService.stop(); this.resources.establishmentService.stop(); @@ -347,6 +348,26 @@ class AccountRuntimeLoop { await this.resources.browserClient.close(); } + /** + * Starts or stops the background media sync only on a mode transition — + * `MediaSyncService.start()` re-wraps its own `stop()` on every call, so + * calling it once per loop tick would pile up closures. + */ + private reconcileMediaSync(enabled: boolean): void { + if (enabled === this.mediaSyncRunning) return; + this.mediaSyncRunning = enabled; + if (!enabled) { + this.resources.mediaSyncService.stop(); + return; + } + this.resources.mediaSyncService.start(); + if (this.startupSyncStarted) return; + this.startupSyncStarted = true; + void this.resources.chatService.runStartupSync() + .then(() => this.resources.chatService.enrichUsernames()) + .catch((error) => this.logger.error(`[account ${this.resources.accountAuth.getSnapshot().redditUsername}] startup sync failed:`, error)); + } + private async run(signal: AbortSignal): Promise { let nextReplyCheckAt = 0; let nextPerformanceCheckAt = 0; @@ -354,7 +375,11 @@ class AccountRuntimeLoop { const performanceInterval = Math.max(this.config.reddit.pollIntervalMs * 5, 60_000); while (!signal.aborted) { try { - if (!this.isBotRunning()) { + const mode = this.getBotMode(); + // Background media sync follows the mode too, so `stopped` really does + // halt every background refresh rather than leaving a 30-minute one. + this.reconcileMediaSync(allowsConversationSync(mode)); + if (!allowsConversationSync(mode)) { await delay(1_000, signal); continue; } @@ -365,7 +390,12 @@ class AccountRuntimeLoop { } if (Date.now() >= nextReplyCheckAt) { nextReplyCheckAt = Date.now() + replyInterval; - const automationResults = await this.resources.conversationAutomation.processPendingConversations(signal); + // In scan mode the same pass refreshes chats and counts actionable + // replies, but stops short of generating or sending any of them. + const automationResults = await this.resources.conversationAutomation.processPendingConversations( + signal, + { scanOnly: !allowsSending(mode) }, + ); for (const result of automationResults) { if (result.kind === 'sent') continue; this.resources.activityLogRepo.insert({ diff --git a/src/web/agent-api-contracts.ts b/src/web/agent-api-contracts.ts index b6b9abd..2e79702 100644 --- a/src/web/agent-api-contracts.ts +++ b/src/web/agent-api-contracts.ts @@ -60,11 +60,14 @@ export const RecentErrorSchema = z.strictObject({ data: z.unknown().optional(), }); +export const BotModeSchema = z.enum(['running', 'scan', 'stopped']); + export const OverviewResponseSchema = z.strictObject({ generatedAt: z.string().datetime(), - status: z.enum(['operational', 'degraded', 'stopped']), + status: z.enum(['operational', 'scanning', 'degraded', 'stopped']), bot: z.strictObject({ running: z.boolean(), + mode: BotModeSchema, dryRun: z.boolean(), authenticated: z.boolean(), messagesThisHour: z.number(), @@ -85,10 +88,18 @@ export const OverviewResponseSchema = z.strictObject({ }), }); -export const ControlRequestSchema = z.strictObject({ running: z.boolean() }); +/** `mode` is authoritative; `running` stays for older agent clients. */ +export const ControlRequestSchema = z.strictObject({ + running: z.boolean().optional(), + mode: BotModeSchema.optional(), +}).refine( + (value) => value.running !== undefined || value.mode !== undefined, + { message: 'Provide either `mode` or `running`' }, +); export const ControlResponseSchema = z.strictObject({ success: z.boolean(), running: z.boolean(), + mode: BotModeSchema, authenticated: z.boolean(), changed: z.boolean(), message: z.string(), diff --git a/src/web/api-catalog.ts b/src/web/api-catalog.ts index 27065e9..2bb1a72 100644 --- a/src/web/api-catalog.ts +++ b/src/web/api-catalog.ts @@ -64,6 +64,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [ operation('getControlStatus', 'GET', '/api/v1/control', 'Get bot running state', ['Control'], { responseSchema: ControlResponseSchema }), operation('setControl', 'POST', '/api/v1/control', 'Set bot running state', ['Control'], { access: 'control', requestSchema: ControlRequestSchema, responseSchema: ControlResponseSchema }), operation('startBotV1', 'POST', '/api/v1/control/start', 'Start bot processing', ['Control'], { access: 'control', responseSchema: ControlResponseSchema }), + operation('scanBotV1', 'POST', '/api/v1/control/scan', 'Keep conversations in sync without sending', ['Control'], { access: 'control', responseSchema: ControlResponseSchema }), operation('stopBotV1', 'POST', '/api/v1/control/stop', 'Stop bot processing', ['Control'], { access: 'control', responseSchema: ControlResponseSchema }), operation('listJobs', 'GET', '/api/v1/jobs', 'List recent asynchronous jobs', ['Jobs'], { responseSchema: z.array(AgentJobSchema) }), operation('getJob', 'GET', '/api/v1/jobs/{id}', 'Get asynchronous job state', ['Jobs'], { responseSchema: AgentJobSchema }), @@ -149,7 +150,9 @@ export const API_OPERATIONS: readonly ApiOperation[] = [ }), operation('deleteMediaLibraryImage', 'DELETE', '/api/media-library/{id}', 'Delete an exchange image', ['Media Library']), operation('startBot', 'POST', '/api/start', 'Start bot processing (legacy)', ['Control']), + operation('scanBot', 'POST', '/api/scan', 'Switch to scan mode (legacy)', ['Control']), operation('stopBot', 'POST', '/api/stop', 'Stop bot processing (legacy)', ['Control']), + operation('setBotMode', 'POST', '/api/mode', 'Set bot mode: running, scan or stopped (legacy)', ['Control']), operation('getTimers', 'GET', '/api/timers', 'Get rate-limit timers', ['Analytics']), operation('getSubredditAnalytics', 'GET', '/api/analytics/by-subreddit', 'Get performance by subreddit', ['Analytics']), operation('getStrategyAnalytics', 'GET', '/api/analytics/by-strategy', 'Get performance by strategy', ['Analytics']), diff --git a/src/web/bot-control.ts b/src/web/bot-control.ts index 215a417..483a9da 100644 --- a/src/web/bot-control.ts +++ b/src/web/bot-control.ts @@ -1,14 +1,29 @@ import type { WebContext } from './context.js'; import type { ControlResponse } from './agent-api-contracts.js'; +import { allowsConversationSync, allowsSending, type BotMode } from '../services/bot-mode.js'; + +/** Past-tense verb used in operator-facing log and activity lines. */ +const TRANSITIONED: Record = { + running: 'started', + scan: 'switched to scan mode', + stopped: 'stopped', +}; + +const STATE: Record = { + running: 'running', + scan: 'in scan mode', + stopped: 'stopped', +}; export function botControlStatus(ctx: WebContext): ControlResponse { - const running = ctx.getBotRunning(); + const mode = ctx.getBotMode(); return { success: true, - running, + running: allowsSending(mode), + mode, authenticated: hasAuthenticatedAccount(ctx), changed: false, - message: running ? 'Bot is running' : 'Bot is stopped', + message: `Bot is ${STATE[mode]}`, }; } @@ -17,40 +32,59 @@ export async function setBotRunning( running: boolean, actor: 'dashboard' | 'agent' = 'dashboard', ): Promise { + return setBotMode(ctx, running ? 'running' : 'stopped', actor); +} + +export async function setBotMode( + ctx: WebContext, + mode: BotMode, + actor: 'dashboard' | 'agent' = 'dashboard', +): Promise { + // Scan mode drives the browser to refresh chats, so it needs a live session + // just as much as running does. + const needsAuth = allowsConversationSync(mode); const authenticated = hasAuthenticatedAccount(ctx) - || (running && await ensureAuthenticatedAccount(ctx)); - if (running && !authenticated) { - ctx.logger.warn(`Bot start blocked for ${actor} until full account authentication`); + || (needsAuth && await ensureAuthenticatedAccount(ctx)); + if (needsAuth && !authenticated) { + ctx.logger.warn(`Bot ${mode} blocked for ${actor} until full account authentication`); return { success: false, running: false, + mode: ctx.getBotMode(), authenticated: false, changed: false, - message: 'Bot start requires a fully authenticated Reddit account', + message: mode === 'scan' + ? 'Scan mode requires a fully authenticated Reddit account' + : 'Bot start requires a fully authenticated Reddit account', }; } - const current = ctx.getBotRunning(); - if (current === running) { + const current = ctx.getBotMode(); + if (current === mode) { return { success: true, - running, + running: allowsSending(mode), + mode, authenticated, changed: false, - message: running ? 'Bot was already running' : 'Bot was already stopped', + message: `Bot was already ${STATE[mode]}`, }; } - ctx.setBotRunning(running); - ctx.activity.push(running ? 'sent' : 'skipped', `Bot ${running ? 'started' : 'stopped'} by ${actor}`); - ctx.logger.info(`Bot ${running ? 'started' : 'stopped'} via ${actor}`); - ctx.broadcaster.dashboardEvents.bot({ running, dryRun: ctx.config.browser.dryRun }); + ctx.setBotMode(mode); + ctx.activity.push( + allowsSending(mode) ? 'sent' : 'skipped', + `Bot ${TRANSITIONED[mode]} by ${actor}`, + ); + ctx.logger.info(`Bot ${TRANSITIONED[mode]} via ${actor}`); + ctx.broadcaster.dashboardEvents.bot({ mode, dryRun: ctx.config.browser.dryRun }); return { success: true, - running, + running: allowsSending(mode), + mode, authenticated, changed: true, - message: `Bot ${running ? 'started' : 'stopped'}`, + message: `Bot ${TRANSITIONED[mode]}`, }; } diff --git a/src/web/context.ts b/src/web/context.ts index 0d66d59..8380f5a 100644 --- a/src/web/context.ts +++ b/src/web/context.ts @@ -28,6 +28,7 @@ import type { CandidateQueueRepository } from '../database/candidate-queue-repos import type { MediaLibraryService } from '../services/media-library.js'; import type { AccountContentRepository } from '../database/account-repositories.js'; import type { PushNotifier } from '../services/push-notifier.js'; +import type { BotMode } from '../services/bot-mode.js'; /** Bag of everything the route modules might need. */ export interface WebContext { @@ -66,7 +67,11 @@ export interface WebContext { pushNotifier?: PushNotifier; }; - /** Bot pause/resume toggle read by the main loop. */ + /** Operator's run/scan/stop intent, read by the main loop. */ + getBotMode(): BotMode; + setBotMode(mode: BotMode): void; + + /** Convenience for `getBotMode() === 'running'`; false while scanning. */ getBotRunning(): boolean; setBotRunning(running: boolean): void; diff --git a/src/web/events/event-adapters.ts b/src/web/events/event-adapters.ts index f2a6c66..4cb9436 100644 --- a/src/web/events/event-adapters.ts +++ b/src/web/events/event-adapters.ts @@ -1,5 +1,6 @@ import type { RealtimeEvent } from './event-types.js'; import type { DashboardEventPublisher } from './event-publisher.js'; +import type { BotMode } from '../../services/bot-mode.js'; type Published = RealtimeEvent; type SyncInput = @@ -13,8 +14,9 @@ const errorReason = (error: unknown) => error instanceof Error ? error.message : export function createDashboardEventAdapters(hub: DashboardEventPublisher) { return { - bot(input: { running: boolean; dryRun: boolean; transitioning?: 'starting' | 'stopping'; reason?: string }): Published { - return hub.publish('bot.status.changed', { status: input.transitioning ?? (input.running ? 'running' : 'stopped'), dryRun: input.dryRun, reason: input.reason }); + bot(input: { mode?: BotMode; running?: boolean; dryRun: boolean; transitioning?: 'starting' | 'stopping'; reason?: string }): Published { + const mode = input.mode ?? (input.running ? 'running' : 'stopped'); + return hub.publish('bot.status.changed', { status: input.transitioning ?? mode, dryRun: input.dryRun, reason: input.reason }); }, messageSent(input: { id: string; username: string; conversationId?: string }): Published { return hub.publish('message.sent', { messageId: input.id, recipient: input.username, conversationId: input.conversationId }); diff --git a/src/web/events/event-types.ts b/src/web/events/event-types.ts index 8782132..3e5ad1e 100644 --- a/src/web/events/event-types.ts +++ b/src/web/events/event-types.ts @@ -1,4 +1,4 @@ -export type BotRuntimeStatus = 'starting' | 'running' | 'stopping' | 'stopped'; +export type BotRuntimeStatus = 'starting' | 'running' | 'scan' | 'stopping' | 'stopped'; export type ConversationStatus = | 'new' diff --git a/src/web/routes/agent.ts b/src/web/routes/agent.ts index f88a90e..caaad82 100644 --- a/src/web/routes/agent.ts +++ b/src/web/routes/agent.ts @@ -2,9 +2,10 @@ import { Router } from 'express'; import type { WebContext } from '../context.js'; import { AgentJobRequestSchema, ControlRequestSchema, SendRedditUserMessageRequestSchema } from '../agent-api-contracts.js'; import { AgentJobManager } from '../agent-job-manager.js'; -import { botControlStatus, setBotRunning } from '../bot-control.js'; +import { botControlStatus, setBotMode } from '../bot-control.js'; import { capabilities, createOpenApiDocument } from '../api-catalog.js'; import { RedditUserMessageError, RedditUserMessenger } from '../../services/reddit-user-messenger.js'; +import { allowsSending } from '../../services/bot-mode.js'; export function createHealthRouter(startedAt = Date.now()): Router { const router = Router(); @@ -60,14 +61,19 @@ export function createAgentRouter(ctx: WebContext): Router { res.status(400).json({ error: 'Invalid control request', detail: body.error.format() }); return; } - const result = await setBotRunning(ctx, body.data.running, 'agent'); + const mode = body.data.mode ?? (body.data.running ? 'running' : 'stopped'); + const result = await setBotMode(ctx, mode, 'agent'); res.status(result.success ? 200 : 409).json(result); }); router.post('/v1/control/start', async (_req, res) => { - const result = await setBotRunning(ctx, true, 'agent'); + const result = await setBotMode(ctx, 'running', 'agent'); res.status(result.success ? 200 : 409).json(result); }); - router.post('/v1/control/stop', async (_req, res) => res.json(await setBotRunning(ctx, false, 'agent'))); + router.post('/v1/control/scan', async (_req, res) => { + const result = await setBotMode(ctx, 'scan', 'agent'); + res.status(result.success ? 200 : 409).json(result); + }); + router.post('/v1/control/stop', async (_req, res) => res.json(await setBotMode(ctx, 'stopped', 'agent'))); router.get('/v1/jobs', (req, res) => { const limit = Number.parseInt(String(req.query.limit ?? '20'), 10); @@ -165,6 +171,7 @@ function buildOverview(ctx: WebContext) { : ctx.getMessagesThisHour(); const authenticated = accounts.some((account) => account.authenticated) || (ctx.services.accountAuth?.isFullyAuthenticated() ?? false); + const mode = ctx.getBotMode(); const recentErrors = ctx.broadcaster.getRecent(100) .filter((entry) => entry.level === 'error') .slice(-10) @@ -173,9 +180,14 @@ function buildOverview(ctx: WebContext) { return { generatedAt: new Date().toISOString(), - status: !ctx.getBotRunning() ? 'stopped' as const : authenticated ? 'operational' as const : 'degraded' as const, + status: mode === 'stopped' + ? 'stopped' as const + : !authenticated + ? 'degraded' as const + : mode === 'scan' ? 'scanning' as const : 'operational' as const, bot: { - running: ctx.getBotRunning(), + running: allowsSending(mode), + mode, dryRun: ctx.config.browser.dryRun, authenticated, messagesThisHour, diff --git a/src/web/routes/control.ts b/src/web/routes/control.ts index c290ff0..f78b3e0 100644 --- a/src/web/routes/control.ts +++ b/src/web/routes/control.ts @@ -1,20 +1,40 @@ import { Router } from 'express'; import type { WebContext } from '../context.js'; -import { setBotRunning } from '../bot-control.js'; +import { setBotMode } from '../bot-control.js'; +import { isBotMode } from '../../services/bot-mode.js'; export function createControlRouter(ctx: WebContext): Router { const router = Router(); router.post('/start', async (_req, res) => { - const result = await setBotRunning(ctx, true); + const result = await setBotMode(ctx, 'running'); res.status(result.success ? 200 : 409).json(result.success - ? { success: true, running: result.running } - : { success: false, running: false, authenticated: false }); + ? { success: true, running: result.running, mode: result.mode } + : { success: false, running: false, mode: result.mode, authenticated: false }); + }); + + router.post('/scan', async (_req, res) => { + const result = await setBotMode(ctx, 'scan'); + res.status(result.success ? 200 : 409).json(result.success + ? { success: true, running: result.running, mode: result.mode } + : { success: false, running: false, mode: result.mode, authenticated: false }); }); router.post('/stop', async (_req, res) => { - const result = await setBotRunning(ctx, false); - res.json({ success: true, running: result.running }); + const result = await setBotMode(ctx, 'stopped'); + res.json({ success: true, running: result.running, mode: result.mode }); + }); + + router.post('/mode', async (req, res) => { + const mode = (req.body as { mode?: unknown } | undefined)?.mode; + if (!isBotMode(mode)) { + res.status(400).json({ success: false, error: 'mode must be one of running, scan, stopped' }); + return; + } + const result = await setBotMode(ctx, mode); + res.status(result.success ? 200 : 409).json(result.success + ? { success: true, running: result.running, mode: result.mode } + : { success: false, running: false, mode: result.mode, authenticated: false }); }); return router; diff --git a/src/web/routes/stats.ts b/src/web/routes/stats.ts index 84a0516..b04043b 100644 --- a/src/web/routes/stats.ts +++ b/src/web/routes/stats.ts @@ -40,6 +40,7 @@ export function createStatsRouter(ctx: WebContext): Router { const messagesThisHour = runtimes.length > 0 ? runtimes.reduce((total, runtime) => total + runtime.resources.rateLimiter.getStats().messagesThisHour, 0) : ctx.getMessagesThisHour(); + const botMode = ctx.getBotMode(); 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; @@ -63,8 +64,10 @@ export function createStatsRouter(ctx: WebContext): Router { ? 'unestablished' : accountCooldown ? 'matrix-cooldown' - : !ctx.getBotRunning() + : botMode === 'stopped' ? 'bot-stopped' + : botMode === 'scan' + ? 'scan-only' : (priority?.processing || (priority?.actionableBacklog ?? 0) > 0) ? 'replies-prioritized' : timers.nextMessageAvailableAt !== null && timers.nextMessageAvailableAt > Date.now() @@ -95,6 +98,7 @@ export function createStatsRouter(ctx: WebContext): Router { const stats: BotStats = { isRunning: ctx.getBotRunning(), + mode: botMode, dryRunMode: ctx.config.browser.dryRun, totalProcessed: runtimes.length > 0 ? runtimes.reduce((total, runtime) => total + runtime.resources.activityLogRepo.countSentSkipped(), 0) diff --git a/src/web/server.ts b/src/web/server.ts index 10cfde1..901a941 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -51,6 +51,7 @@ import type { CandidateQueueRepository } from '../database/candidate-queue-repos import type { MediaLibraryService } from '../services/media-library.js'; import { createMediaLibraryRouter } from './routes/media-library.js'; import type { AccountContentRepository } from '../database/account-repositories.js'; +import { allowsSending, type BotMode } from '../services/bot-mode.js'; export type { BotStats, ActivityItem, LogEntry, RuntimeConfig } from './types.js'; @@ -67,7 +68,7 @@ export class WebServer { private server: Server | null = null; private activity: ActivityLog; private broadcaster: LogBroadcaster; - private botRunning = false; + private botMode: BotMode = 'stopped'; private messagesThisHour = 0; private runtimeConfig: RuntimeConfig; private onConfigChange: ((config: RuntimeConfig) => void) | null = null; @@ -85,11 +86,11 @@ export class WebServer { broadcaster?: LogBroadcaster, private workspaceDirectory = process.cwd(), private aiUsageRepo?: AiUsageRepository, - initialBotRunning = false, - private readonly persistBotRunning: (running: boolean) => void = () => undefined, + initialBotMode: BotMode = 'stopped', + private readonly persistBotMode: (mode: BotMode) => void = () => undefined, private readonly accountContentRepo?: AccountContentRepository, ) { - this.botRunning = initialBotRunning; + this.botMode = initialBotMode; this.broadcaster = broadcaster ?? new LogBroadcaster(); this.activity = new ActivityLog(activityLogRepo); this.runtimeConfig = { @@ -125,10 +126,15 @@ export class WebServer { accountContent: this.accountContentRepo, }, services: this.services, - getBotRunning: () => this.botRunning, + getBotMode: () => this.botMode, + setBotMode: (mode) => { + this.botMode = mode; + this.persistBotMode(mode); + }, + getBotRunning: () => allowsSending(this.botMode), setBotRunning: (running) => { - this.botRunning = running; - this.persistBotRunning(running); + this.botMode = running ? 'running' : 'stopped'; + this.persistBotMode(this.botMode); }, getMessagesThisHour: () => this.messagesThisHour, getOnConfigChange: () => this.onConfigChange, @@ -196,8 +202,13 @@ export class WebServer { // ---------- public API used by the bootstrapper ---------- + /** True only in `running` mode — scan mode deliberately reads as not-running. */ isRunning(): boolean { - return this.botRunning; + return allowsSending(this.botMode); + } + + getMode(): BotMode { + return this.botMode; } get dashboardEvents(): LogBroadcaster['dashboardEvents'] { diff --git a/src/web/types.ts b/src/web/types.ts index f391ce6..4d6e7ee 100644 --- a/src/web/types.ts +++ b/src/web/types.ts @@ -1,5 +1,7 @@ export interface BotStats { isRunning: boolean; + /** `running` sends; `scan` keeps conversations in sync without sending. */ + mode: 'running' | 'scan' | 'stopped'; dryRunMode: boolean; totalProcessed: number; totalMessagesSent: number; @@ -15,7 +17,7 @@ export interface BotStats { 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' | 'unestablished'; + sendingState: 'ready' | 'bot-stopped' | 'scan-only' | 'runtime-stopped' | 'runtime-failed' | 'login-required' | 'matrix-cooldown' | 'replies-prioritized' | 'pacing' | 'unestablished'; messagesThisHour: number; maxMessagesPerHour: number; nextMessageAvailableAt: number | null; diff --git a/tests/bot-mode.test.ts b/tests/bot-mode.test.ts new file mode 100644 index 0000000..c0c1353 --- /dev/null +++ b/tests/bot-mode.test.ts @@ -0,0 +1,72 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; +import { + allowsConversationSync, + allowsDiscovery, + allowsSending, + isBotMode, + parseBotMode, +} from '../src/services/bot-mode.js'; +import { RuntimeStateRepository } from '../src/database/candidate-queue-repository.js'; +import { runMigrations } from '../src/database/client.js'; + +describe('bot mode capabilities', () => { + it('lets scan sync conversations without sending or discovering', () => { + expect(allowsConversationSync('scan')).toBe(true); + expect(allowsSending('scan')).toBe(false); + expect(allowsDiscovery('scan')).toBe(false); + }); + + it('lets running do everything and stopped do nothing', () => { + for (const capability of [allowsConversationSync, allowsSending, allowsDiscovery]) { + expect(capability('running')).toBe(true); + expect(capability('stopped')).toBe(false); + } + }); + + it('falls back to stopped rather than guessing a sending mode', () => { + expect(parseBotMode('scan')).toBe('scan'); + expect(parseBotMode(null)).toBe('stopped'); + expect(parseBotMode('paused')).toBe('stopped'); + expect(parseBotMode(undefined, 'running')).toBe('running'); + expect(isBotMode('paused')).toBe(false); + }); +}); + +describe('runtime state persistence', () => { + function db(): Database.Database { + const database = new Database(':memory:'); + runMigrations(database); + return database; + } + + function repo(): RuntimeStateRepository { + return new RuntimeStateRepository(db()); + } + + it('round-trips every mode', () => { + const state = repo(); + for (const mode of ['running', 'scan', 'stopped'] as const) { + state.setBotMode(mode); + expect(state.getBotMode()).toBe(mode); + } + }); + + it('reports scan as not-running for the legacy boolean readers', () => { + const state = repo(); + state.setBotMode('scan'); + expect(state.isBotRunning()).toBe(false); + state.setBotRunning(true); + expect(state.getBotMode()).toBe('running'); + }); + + it('defaults to stopped before anything has been persisted', () => { + expect(repo().getBotMode()).toBe('stopped'); + }); + + it('reads a pre-migration row that only has the boolean', () => { + const database = db(); + database.prepare('INSERT INTO runtime_state (id, bot_running, bot_mode, updated_at) VALUES (1, 1, NULL, 0)').run(); + expect(new RuntimeStateRepository(database).getBotMode()).toBe('running'); + }); +}); diff --git a/tests/control-auth.test.ts b/tests/control-auth.test.ts index 0877e01..38541c7 100644 --- a/tests/control-auth.test.ts +++ b/tests/control-auth.test.ts @@ -13,7 +13,7 @@ afterEach(async () => { describe('bot control account authentication gate', () => { it('refuses to start the bot until full account authentication', async () => { - const setBotRunning = vi.fn(); + const setBotMode = vi.fn(); const app = express(); app.use(createControlRouter({ services: { @@ -22,7 +22,8 @@ describe('bot control account authentication gate', () => { ensureFullyAuthenticated: vi.fn(async () => false), }, }, - setBotRunning, + getBotMode: () => 'stopped', + setBotMode, activity: { push: vi.fn() }, logger: { warn: vi.fn(), info: vi.fn() }, } as never)); @@ -35,7 +36,7 @@ describe('bot control account authentication gate', () => { expect(response.status).toBe(409); expect(await response.json()).toMatchObject({ success: false, authenticated: false }); - expect(setBotRunning).not.toHaveBeenCalled(); + expect(setBotMode).not.toHaveBeenCalled(); }); it('renews an expired authentication lease before starting the bot', async () => { @@ -50,8 +51,8 @@ describe('bot control account authentication gate', () => { ensureFullyAuthenticated, }, }, - getBotRunning: () => running, - setBotRunning: (next: boolean) => { running = next; }, + getBotMode: () => (running ? 'running' : 'stopped'), + setBotMode: (next: string) => { running = next === 'running'; }, activity: { push: vi.fn() }, logger: { warn: vi.fn(), info: vi.fn() }, broadcaster: { dashboardEvents: { bot: vi.fn() } }, @@ -86,8 +87,8 @@ describe('bot control account authentication gate', () => { get: () => runtime, }, }, - getBotRunning: () => running, - setBotRunning: (next: boolean) => { running = next; }, + getBotMode: () => (running ? 'running' : 'stopped'), + setBotMode: (next: string) => { running = next === 'running'; }, activity: { push: vi.fn() }, logger: { warn: vi.fn(), info: vi.fn() }, broadcaster: { dashboardEvents: { bot: vi.fn() } }, @@ -103,4 +104,84 @@ describe('bot control account authentication gate', () => { expect(ensureFullyAuthenticated).toHaveBeenCalledOnce(); expect(running).toBe(true); }); + it('refuses scan mode too — it drives the browser to refresh chats', async () => { + const setBotMode = vi.fn(); + const app = express(); + app.use(createControlRouter({ + services: { + accountAuth: { + isFullyAuthenticated: () => false, + ensureFullyAuthenticated: vi.fn(async () => false), + }, + }, + getBotMode: () => 'stopped', + setBotMode, + activity: { push: vi.fn() }, + logger: { warn: vi.fn(), info: vi.fn() }, + } as never)); + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + + const response = await fetch(`http://127.0.0.1:${port}/scan`, { method: 'POST' }); + + expect(response.status).toBe(409); + expect(setBotMode).not.toHaveBeenCalled(); + }); + + it('rejects an unknown mode without touching runtime state', async () => { + const setBotMode = vi.fn(); + const app = express(); + app.use(express.json()); + app.use(createControlRouter({ + services: { accountAuth: { isFullyAuthenticated: () => true } }, + getBotMode: () => 'stopped', + setBotMode, + activity: { push: vi.fn() }, + logger: { warn: vi.fn(), info: vi.fn() }, + } as never)); + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + + const response = await fetch(`http://127.0.0.1:${port}/mode`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'paused' }), + }); + + expect(response.status).toBe(400); + expect(setBotMode).not.toHaveBeenCalled(); + }); + + it('switches a running bot into scan mode', async () => { + let mode = 'running'; + const app = express(); + app.use(express.json()); + app.use(createControlRouter({ + config: { browser: { dryRun: false } }, + services: { accountAuth: { isFullyAuthenticated: () => true } }, + getBotMode: () => mode, + setBotMode: (next: string) => { mode = next; }, + activity: { push: vi.fn() }, + logger: { warn: vi.fn(), info: vi.fn() }, + broadcaster: { dashboardEvents: { bot: vi.fn() } }, + } as never)); + const server = app.listen(0); + servers.push(server); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + + const response = await fetch(`http://127.0.0.1:${port}/mode`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'scan' }), + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ success: true, running: false, mode: 'scan' }); + expect(mode).toBe('scan'); + }); }); diff --git a/tests/conversation-automation.test.ts b/tests/conversation-automation.test.ts index 3043953..d0c6449 100644 --- a/tests/conversation-automation.test.ts +++ b/tests/conversation-automation.test.ts @@ -505,6 +505,22 @@ describe('conversation automation', () => { expect(claims.markAttempt).toHaveBeenCalledWith('room-alice', 'in-1'); }); + it('refreshes chats and keeps the backlog visible in scan mode without replying', async () => { + const { service, chat, ai } = harness(); + chat.getCachedConversations.mockResolvedValue([incoming(1)]); + + const results = await service.processPendingConversations(undefined, { scanOnly: true }); + + expect(results).toEqual([]); + expect(chat.refreshTopChats).toHaveBeenCalledWith(25); + // No AI spend and no outbound traffic — the reply is left for a human. + expect(ai.generateReply).not.toHaveBeenCalled(); + expect(ai.generateMediaAsk).not.toHaveBeenCalled(); + expect(chat.sendChatMessage).not.toHaveBeenCalled(); + expect(service.getPriorityStatus()).toMatchObject({ actionableBacklog: 1 }); + expect(await service.hasActionableReplies()).toBe(true); + }); + it('refreshes a wider reply window and drains actionable conversations oldest first', async () => { const { service, chat } = harness(); const newer = { ...incoming(2), username: 'newer', roomId: 'room-newer', url: '/room/newer' }; diff --git a/tests/dashboard/app.test.tsx b/tests/dashboard/app.test.tsx index 6158172..96bd149 100644 --- a/tests/dashboard/app.test.tsx +++ b/tests/dashboard/app.test.tsx @@ -12,6 +12,7 @@ import { createDashboardRoutes } from '../../apps/dashboard/src/app/router.js'; interface TestStatsOverrides { isRunning?: boolean; + mode?: 'running' | 'scan' | 'stopped'; recentActivity?: readonly unknown[]; accounts?: readonly unknown[]; } @@ -21,6 +22,7 @@ const server = setupServer(); function createStats(overrides: TestStatsOverrides = {}) { return { isRunning: overrides.isRunning ?? false, + mode: overrides.mode ?? (overrides.isRunning ? 'running' : 'stopped'), dryRunMode: true, totalProcessed: 18, totalMessagesSent: 12, @@ -402,9 +404,9 @@ describe('dashboard foundation', () => { installSuccessfulApi(); let startRequests = 0; server.use( - http.post('/api/start', () => { + http.post('/api/mode', () => { startRequests += 1; - return HttpResponse.json({ success: true, running: true }); + return HttpResponse.json({ success: true, running: true, mode: 'running' }); }), ); const user = userEvent.setup(); @@ -424,10 +426,32 @@ describe('dashboard foundation', () => { expect(startRequests).toBe(1); }); + it('offers scan mode alongside start and posts the chosen mode', async () => { + installSuccessfulApi(); + let requested: string | null = null; + server.use( + http.post('/api/mode', async ({ request }) => { + requested = ((await request.json()) as { mode: string }).mode; + return HttpResponse.json({ success: true, running: false, mode: 'scan' }); + }), + ); + const user = userEvent.setup(); + renderDashboard(); + + await user.click(await screen.findByRole('button', { name: 'Scan mode' })); + const dialog = screen.getByRole('dialog', { name: 'Switch to scan mode?' }); + expect(dialog).toHaveTextContent('nothing is sent'); + + await user.click(within(dialog).getByRole('button', { name: 'Confirm scan mode' })); + + expect(await screen.findByText('Scanning', { selector: '[data-bot-status]' })).toBeInTheDocument(); + expect(requested).toBe('scan'); + }); + it('shows a start failure inside the confirmation dialog instead of a stray toast', async () => { installSuccessfulApi(); server.use( - http.post('/api/start', () => HttpResponse.json({ error: 'Browser runtime is not ready' }, { status: 503 })), + http.post('/api/mode', () => HttpResponse.json({ error: 'Browser runtime is not ready' }, { status: 503 })), ); const user = userEvent.setup(); renderDashboard(); diff --git a/tests/dashboard/shell-status.test.ts b/tests/dashboard/shell-status.test.ts index fe9e480..daacdc1 100644 --- a/tests/dashboard/shell-status.test.ts +++ b/tests/dashboard/shell-status.test.ts @@ -4,16 +4,23 @@ import { deriveShellStatus } from '../../apps/dashboard/src/app/shell-status.js' describe('global shell status', () => { it('derives safety-critical labels from authoritative snapshots', () => { expect(deriveShellStatus({ - stats: { isRunning: true, dryRunMode: true, invitationCooldown: { reason: 'Reddit cooldown' } }, + stats: { mode: 'running', dryRunMode: true, invitationCooldown: { reason: 'Reddit cooldown' } }, reddit: { ready: true, loggedIn: true, status: 'authenticated' }, realtime: { status: 'reconnecting', isStale: true }, sync: { syncing: true, syncingKind: 'full' }, })).toEqual({ bot: 'Running (dry run)', reddit: 'Reddit authenticated', realtime: 'Realtime reconnecting (stale)', - cooldown: 'Reddit cooldown', sync: 'Full sync active', controlAction: 'stop', controlDisabled: false, + cooldown: 'Reddit cooldown', sync: 'Full sync active', controlAction: 'stopped', controlDisabled: false, }); }); + it('labels scan mode and offers a full start as the next step', () => { + expect(deriveShellStatus({ + stats: { mode: 'scan', dryRunMode: false, invitationCooldown: null }, + realtime: { status: 'connected', isStale: false }, + })).toMatchObject({ bot: 'Scanning', controlAction: 'running' }); + }); + it('guards bot controls until the authoritative bot state is available', () => { expect(deriveShellStatus({ realtime: { status: 'idle', isStale: true } })).toMatchObject({ bot: 'Bot state unavailable', controlDisabled: true, diff --git a/tests/reddit-account-runtime-builder.test.ts b/tests/reddit-account-runtime-builder.test.ts index 060fa9b..f21a3e9 100644 --- a/tests/reddit-account-runtime-builder.test.ts +++ b/tests/reddit-account-runtime-builder.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { RedditPost } from '../src/reddit/api.js'; import type { RedditAccountRuntimeResources } from '../src/services/reddit-account-runtime.js'; -import { materializePreparedPostForAccount } from '../src/services/reddit-account-runtime-builder.js'; +import { AccountRuntimeLoop, materializePreparedPostForAccount } from '../src/services/reddit-account-runtime-builder.js'; import type { ProcessedPost } from '../src/services/processor.js'; describe('materializePreparedPostForAccount', () => { @@ -38,3 +38,91 @@ describe('materializePreparedPostForAccount', () => { expect(updateAnalysis).toHaveBeenCalledWith('post-1', 'analysis', 'message'); }); }); + +describe('AccountRuntimeLoop mode gating', () => { + function loopHarness(getBotMode: () => 'running' | 'scan' | 'stopped') { + const conversationAutomation = { processPendingConversations: vi.fn().mockResolvedValue([]) }; + const responseChecker = { checkForResponses: vi.fn().mockResolvedValue([]) }; + const mediaSyncService = { start: vi.fn(), stop: vi.fn() }; + const chatService = { + runStartupSync: vi.fn().mockResolvedValue(undefined), + enrichUsernames: vi.fn().mockResolvedValue(undefined), + }; + const resources = { + conversationAutomation, + responseChecker, + mediaSyncService, + chatService, + accountAuth: { + isFullyAuthenticated: () => true, + getSnapshot: () => ({ redditUsername: 'tester' }), + }, + activityLogRepo: { insert: vi.fn() }, + recipientAccountStatusService: { start: vi.fn(), stop: vi.fn() }, + establishmentService: { start: vi.fn(), stop: vi.fn() }, + browserClient: { close: vi.fn().mockResolvedValue(undefined) }, + }; + const config = { reddit: { pollIntervalMs: 1_000 } }; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const loop = new AccountRuntimeLoop( + resources as never, config as never, logger as never, getBotMode, { } as never, + ); + return { loop, conversationAutomation, responseChecker, mediaSyncService, chatService }; + } + + /** Lets the loop complete at least one full pass before we assert. */ + async function runBriefly(loop: AccountRuntimeLoop): Promise { + loop.start(); + await new Promise((resolve) => setTimeout(resolve, 30)); + await loop.stop(); + } + + it('keeps conversations in sync in scan mode but suppresses replies', async () => { + const fixture = loopHarness(() => 'scan'); + + await runBriefly(fixture.loop); + + expect(fixture.conversationAutomation.processPendingConversations) + .toHaveBeenCalledWith(expect.anything(), { scanOnly: true }); + expect(fixture.responseChecker.checkForResponses).toHaveBeenCalled(); + expect(fixture.mediaSyncService.start).toHaveBeenCalled(); + expect(fixture.chatService.runStartupSync).toHaveBeenCalled(); + }); + + it('sends replies in running mode', async () => { + const fixture = loopHarness(() => 'running'); + + await runBriefly(fixture.loop); + + expect(fixture.conversationAutomation.processPendingConversations) + .toHaveBeenCalledWith(expect.anything(), { scanOnly: false }); + }); + + it('halts every background refresh when stopped', async () => { + const fixture = loopHarness(() => 'stopped'); + + await runBriefly(fixture.loop); + + expect(fixture.conversationAutomation.processPendingConversations).not.toHaveBeenCalled(); + expect(fixture.responseChecker.checkForResponses).not.toHaveBeenCalled(); + expect(fixture.mediaSyncService.start).not.toHaveBeenCalled(); + expect(fixture.chatService.runStartupSync).not.toHaveBeenCalled(); + }); + + it('stops the background media sync when the operator drops from scan to stopped', async () => { + let mode: 'running' | 'scan' | 'stopped' = 'scan'; + const fixture = loopHarness(() => mode); + + fixture.loop.start(); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect(fixture.mediaSyncService.start).toHaveBeenCalledTimes(1); + + mode = 'stopped'; + await new Promise((resolve) => setTimeout(resolve, 30)); + await fixture.loop.stop(); + + expect(fixture.mediaSyncService.stop).toHaveBeenCalled(); + // Only ever started once — the reconcile must not re-enter on every tick. + expect(fixture.mediaSyncService.start).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/web/control-realtime.test.ts b/tests/web/control-realtime.test.ts index 08abc2b..ca295ed 100644 --- a/tests/web/control-realtime.test.ts +++ b/tests/web/control-realtime.test.ts @@ -1,4 +1,5 @@ import express from 'express'; +import type { BotMode } from '../../src/services/bot-mode.js'; import type { AddressInfo } from 'node:net'; import { describe, expect, it, vi } from 'vitest'; import type { WebContext } from '../../src/web/context.js'; @@ -6,13 +7,13 @@ import { createControlRouter } from '../../src/web/routes/control.js'; describe('control route realtime events', () => { it('publishes the authoritative bot state after each successful transition', async () => { - let running = false; + let mode: BotMode = 'stopped'; const bot = vi.fn(); const context = { config: { browser: { dryRun: true } }, services: { accountAuth: { isFullyAuthenticated: () => true } }, - getBotRunning: () => running, - setBotRunning: (next: boolean) => { running = next; }, + getBotMode: () => mode, + setBotMode: (next: BotMode) => { mode = next; }, activity: { push: vi.fn() }, logger: { info: vi.fn() }, broadcaster: { dashboardEvents: { bot } }, @@ -24,12 +25,16 @@ describe('control route realtime events', () => { try { expect((await fetch(`http://127.0.0.1:${port}/api/start`, { method: 'POST' })).status).toBe(200); - expect(running).toBe(true); - expect(bot).toHaveBeenLastCalledWith({ running: true, dryRun: true }); + expect(mode).toBe('running'); + expect(bot).toHaveBeenLastCalledWith({ mode: 'running', dryRun: true }); + + expect((await fetch(`http://127.0.0.1:${port}/api/scan`, { method: 'POST' })).status).toBe(200); + expect(mode).toBe('scan'); + expect(bot).toHaveBeenLastCalledWith({ mode: 'scan', dryRun: true }); expect((await fetch(`http://127.0.0.1:${port}/api/stop`, { method: 'POST' })).status).toBe(200); - expect(running).toBe(false); - expect(bot).toHaveBeenLastCalledWith({ running: false, dryRun: true }); + expect(mode).toBe('stopped'); + expect(bot).toHaveBeenLastCalledWith({ mode: 'stopped', dryRun: true }); } finally { await new Promise((resolve) => server.close(() => resolve())); } diff --git a/tests/web/stats-route.test.ts b/tests/web/stats-route.test.ts index aef6816..a58e4cc 100644 --- a/tests/web/stats-route.test.ts +++ b/tests/web/stats-route.test.ts @@ -75,6 +75,7 @@ describe('stats route account status', () => { repos: { messages: { getRecent: () => [] } }, activity: { recent: () => [], totalProcessed: () => 0 }, getBotRunning: () => true, + getBotMode: () => 'running', getMessagesThisHour: () => 0, } as unknown as WebContext; const app = express().use('/api', createStatsRouter(context));