From 14d92e14ee3ab674e2056e11a1168d304fc532e3 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Tue, 11 Aug 2026 10:16:24 +0100 Subject: [PATCH] feat: add scan mode so conversations stay in sync without sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopping the bot previously halted conversation syncing too. On the multi-account path the run-flag check sat above both the reply-sync and response-check branches, so a stop left only MediaSyncService's coarse 30-minute chat refresh. The legacy single-account path did the opposite and kept syncing while stopped — one boolean meaning two things. Replace the boolean with an explicit three-way mode: running — discover, screen, send, automate replies scan — refresh chats, detect replies, check responses; send nothing and discover nothing; actionable replies surface as backlog stopped — everything halts, including background media sync scan differs from browser.dryRun, which still discovers and pays for AI screening before suppressing the send. The mode persists in runtime_state.bot_mode; a migration backfills it from bot_running, and rows written by an older build still read correctly. Both loops now gate on the mode, the account runtime loop reconciles background media sync on transitions only, and the dashboard and agent API expose all three states. Co-Authored-By: Claude Opus 5 --- apps/dashboard/src/api/client.ts | 15 ++- apps/dashboard/src/api/contracts.ts | 1 + apps/dashboard/src/api/queries.ts | 6 +- apps/dashboard/src/app/shell-status-panel.tsx | 11 ++- apps/dashboard/src/app/shell-status.ts | 17 +++- .../src/components/confirmation-dialog.tsx | 38 ++++++-- .../src/features/overview/overview-page.tsx | 43 ++++++--- apps/dashboard/src/styles.css | 1 + docs/api.md | 3 + packages/api-contracts/src/common.ts | 3 + packages/api-contracts/src/stats.ts | 3 + .../api-contracts/tests/contracts.test.ts | 12 ++- src/bootstrap.ts | 6 +- src/database/candidate-queue-repository.ts | 30 ++++-- src/database/client.ts | 7 ++ src/main-loop.ts | 20 ++-- src/services/bot-mode.ts | 48 ++++++++++ src/services/conversation-automation.ts | 20 +++- .../reddit-account-runtime-builder.ts | 52 +++++++--- src/web/agent-api-contracts.ts | 15 ++- src/web/api-catalog.ts | 3 + src/web/bot-control.ts | 68 +++++++++---- src/web/context.ts | 7 +- src/web/events/event-adapters.ts | 6 +- src/web/events/event-types.ts | 2 +- src/web/routes/agent.ts | 24 +++-- src/web/routes/control.ts | 32 +++++-- src/web/routes/stats.ts | 6 +- src/web/server.ts | 27 ++++-- src/web/types.ts | 4 +- tests/bot-mode.test.ts | 72 ++++++++++++++ tests/control-auth.test.ts | 95 +++++++++++++++++-- tests/conversation-automation.test.ts | 16 ++++ tests/dashboard/app.test.tsx | 30 +++++- tests/dashboard/shell-status.test.ts | 11 ++- tests/reddit-account-runtime-builder.test.ts | 90 +++++++++++++++++- tests/web/control-realtime.test.ts | 19 ++-- tests/web/stats-route.test.ts | 1 + 38 files changed, 724 insertions(+), 140 deletions(-) create mode 100644 src/services/bot-mode.ts create mode 100644 tests/bot-mode.test.ts 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));