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

+72
View File
@@ -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');
});
});
+88 -7
View File
@@ -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<void>((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<void>((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<void>((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');
});
});
+16
View File
@@ -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' };
+27 -3
View File
@@ -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();
+9 -2
View File
@@ -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,
+89 -1
View File
@@ -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<void> {
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);
});
});
+12 -7
View File
@@ -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<void>((resolve) => server.close(() => resolve()));
}
+1
View File
@@ -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));