feat: add scan mode so conversations stay in sync without sending

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 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-11 10:16:24 +01:00
1 parent 3a40651930
commit 14d92e14ee
38 files changed
+724 -140

No files matched your search

+3 -3
View File
@@ -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,
);
+22 -8
View File
@@ -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);
}
}
+7
View File
@@ -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;
+13 -7
View File
@@ -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<void> {
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<void> {
const discoveryLoop = (async (): Promise<void> => {
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<void> {
const screeningLoop = (async (): Promise<void> => {
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<void> {
const dispatchLoop = (async (): Promise<void> => {
while (!controller.signal.aborted) {
if (!(webServer?.isRunning() ?? true)) {
if (!allowsDiscovery(webServer?.getMode() ?? 'running')) {
await sleep(1_000, controller.signal).catch(() => undefined);
continue;
}
+48
View File
@@ -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';
}
}
+19 -1
View File
@@ -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<string, ConversationReplyScreening>();
async processPendingConversations(signal?: AbortSignal): Promise<ConversationAutomationResult[]> {
/**
* 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<ConversationAutomationResult[]> {
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);
+41 -11
View File
@@ -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<void> | 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<void> {
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<void> {
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({
+13 -2
View File
@@ -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(),
+3
View File
@@ -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']),
+51 -17
View File
@@ -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<BotMode, string> = {
running: 'started',
scan: 'switched to scan mode',
stopped: 'stopped',
};
const STATE: Record<BotMode, string> = {
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<ControlResponse> {
return setBotMode(ctx, running ? 'running' : 'stopped', actor);
}
export async function setBotMode(
ctx: WebContext,
mode: BotMode,
actor: 'dashboard' | 'agent' = 'dashboard',
): Promise<ControlResponse> {
// 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]}`,
};
}
+6 -1
View File
@@ -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;
+4 -2
View File
@@ -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 });
+1 -1
View File
@@ -1,4 +1,4 @@
export type BotRuntimeStatus = 'starting' | 'running' | 'stopping' | 'stopped';
export type BotRuntimeStatus = 'starting' | 'running' | 'scan' | 'stopping' | 'stopped';
export type ConversationStatus =
| 'new'
+18 -6
View File
@@ -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,
+26 -6
View File
@@ -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;
+5 -1
View File
@@ -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)
+19 -8
View File
@@ -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'] {
+3 -1
View File
@@ -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;