Add manual outreach funnel clearing

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:02:18 +01:00
1 parent 6b2e1bc380
commit 4798385d8b
16 files changed
+319 -17

No files matched your search

@@ -0,0 +1,48 @@
import { Modal } from '../../components/modal.js';
interface ClearFunnelDialogProps {
clearableCount: number;
sendingCount: number;
errorMessage?: string | null;
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function ClearFunnelDialog({
clearableCount,
sendingCount,
errorMessage = null,
isPending,
onCancel,
onConfirm,
}: ClearFunnelDialogProps) {
return (
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="clear-funnel-title" onClose={onCancel}>
<span className="eyebrow">Confirm queue action</span>
<h2 id="clear-funnel-title">Clear the outreach funnel?</h2>
<p>
This will cancel {clearableCount} waiting, screening, prepared, or retrying
{clearableCount === 1 ? ' candidate' : ' candidates'}. Cleared candidates remain in the audit history and will
not be rediscovered.
</p>
{sendingCount > 0 ? (
<p><strong>{sendingCount} in-flight {sendingCount === 1 ? 'send' : 'sends'} will be left alone</strong> because delivery may already be crossing the remote-send boundary.</p>
) : null}
<p>If the bot is running, discovery may begin filling the funnel again immediately.</p>
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
<div className="dialog-actions">
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
<button
aria-busy={isPending || undefined}
className="button danger"
disabled={isPending}
onClick={onConfirm}
type="button"
>
{isPending ? 'Clearing…' : 'Clear funnel'}
</button>
</div>
</Modal>
);
}
@@ -1,4 +1,7 @@
import { OutreachFunnelResponseSchema } from '../../../../../packages/api-contracts/src/index.js';
import {
OutreachClearResponseSchema,
OutreachFunnelResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js';
export function createOutreachApi(fetchImplementation?: typeof fetch) {
@@ -16,6 +19,18 @@ export function createOutreachApi(fetchImplementation?: typeof fetch) {
}
return OutreachFunnelResponseSchema.parse(payload);
},
async clearFunnel() {
const response = await request('/api/outreach', { method: 'DELETE' });
const payload: unknown = await response.json();
if (!response.ok) {
const message = typeof payload === 'object' && payload !== null && 'error' in payload
&& typeof payload.error === 'string'
? payload.error
: `Could not clear outreach funnel (${response.status})`;
throw new Error(message);
}
return OutreachClearResponseSchema.parse(payload);
},
};
}
@@ -1,11 +1,12 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { OriginPostCard } from '../../components/origin-post.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
import { outreachApi } from './outreach-api.js';
import { ClearFunnelDialog } from './clear-funnel-dialog.js';
import './outreach.css';
const STAGES: ReadonlyArray<{
@@ -121,11 +122,17 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
}
export function OutreachPage() {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ['outreach', 'funnel'],
queryFn: outreachApi.getFunnel,
refetchInterval: 10_000,
});
const clearMutation = useMutation({
mutationFn: outreachApi.clearFunnel,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }),
});
const [isConfirmingClear, setIsConfirmingClear] = useState(false);
// Single client-side clock for every displayed offset: retry countdowns tick
// every second while visible, everything else every 10 seconds.
@@ -156,25 +163,46 @@ export function OutreachPage() {
}
const { data } = query;
const clearableCount = data.activeCount - data.stages.sending;
const clearResult = clearMutation.data;
return (
<div className="page outreach-page">
<header className="page-header">
<div><span className="eyebrow">Live candidate queue</span><h1>Outreach funnel</h1><p>Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.</p></div>
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
<div className="header-actions">
<button
className="button danger"
disabled={clearableCount === 0 || clearMutation.isPending}
onClick={() => { clearMutation.reset(); setIsConfirmingClear(true); }}
type="button"
>
Clear funnel
</button>
<button
aria-busy={query.isFetching || undefined}
className="button secondary"
disabled={query.isFetching}
onClick={() => void query.refetch()}
type="button"
>
{query.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
</div>
</header>
{clearResult ? (
<div className="outreach-clear-notice" role="status">
Cleared {clearResult.cleared.total} unsent {clearResult.cleared.total === 1 ? 'candidate' : 'candidates'}.
{clearResult.retainedSending > 0
? ` ${clearResult.retainedSending} in-flight ${clearResult.retainedSending === 1 ? 'send was' : 'sends were'} retained.`
: ''}
</div>
) : null}
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
<div className="runway-heading">
<div><span className="eyebrow">Current burn-down</span><h2 id="active-funnel-heading">{data.activeCount} active {data.activeCount === 1 ? 'post' : 'posts'}</h2></div>
<div className="runway-outcomes"><span><strong>{data.outcomes.sent}</strong> sent</span><span><strong>{data.outcomes.rejected}</strong> screened out</span></div>
<div className="runway-outcomes"><span><strong>{data.outcomes.sent}</strong> sent</span><span><strong>{data.outcomes.rejected}</strong> not sent</span></div>
</div>
<ol className="outreach-stages">
{STAGES.map((stage) => (
@@ -185,7 +213,7 @@ export function OutreachPage() {
</li>
))}
</ol>
<p className="runway-caption">Retrying posts loop back to Ready when their cooldown expires. Sent and screened-out totals cover the lifetime of this queue.</p>
<p className="runway-caption">Retrying posts loop back to Ready when their cooldown expires. Sent and not-sent totals, including manually cleared candidates, cover the lifetime of this queue.</p>
</section>
<section aria-labelledby="active-candidates-heading" className="candidate-section">
@@ -204,6 +232,16 @@ export function OutreachPage() {
</ol>
)}
</section>
{isConfirmingClear ? (
<ClearFunnelDialog
clearableCount={clearableCount}
errorMessage={clearMutation.isError ? clearMutation.error.message : null}
isPending={clearMutation.isPending}
onCancel={() => { setIsConfirmingClear(false); clearMutation.reset(); }}
onConfirm={() => clearMutation.mutate(undefined, { onSuccess: () => setIsConfirmingClear(false) })}
sendingCount={data.stages.sending}
/>
) : null}
</div>
);
}
@@ -1,4 +1,5 @@
.outreach-page { display: grid; gap: 1rem; }
.outreach-clear-notice { padding: .75rem 1rem; color: var(--accent); background: rgba(117, 214, 157, .08); border: 1px solid rgba(117, 214, 157, .3); border-radius: .7rem; }
.outreach-runway { position: relative; overflow: hidden; padding: 1.2rem; background: #101a16; border: 1px solid #355044; border-radius: 1rem; }
.outreach-runway::before { position: absolute; inset: 0; content: ""; pointer-events: none; background: radial-gradient(circle at 12% -20%, rgba(117, 214, 157, .16), transparent 34%); }
.runway-heading { position: relative; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.2rem; }
+1
View File
@@ -108,6 +108,7 @@ an administrator credential.
| `GET` | `/api/analytics/ai-usage` | Get OpenRouter cost and model reliability | read | no |
| `GET` | `/api/attention` | Get operator attention items | read | no |
| `GET` | `/api/outreach` | Get active outreach candidates and funnel stages | read | no |
| `DELETE` | `/api/outreach` | Cancel every unsent outreach candidate except in-flight sends | admin | yes |
| `GET` | `/api/growth` | Get per-account comment growth state | read | no |
| `GET` | `/api/growth/{accountId}` | Get one account's growth settings, communities and comment history | read | no |
| `PATCH` | `/api/growth/{accountId}` | Enable, disable or re-pace comment growth for an account | admin | yes |
+2
View File
@@ -46,6 +46,8 @@ An account is eligible for new outreach only when its runtime is running, both i
Matrix cooldowns retain the operation that triggered them. A room-creation or invitation limit pauses new outreach but does not suppress replies in existing rooms; a limit returned while sending into an existing room pauses those sends as well.
The operator can manually clear the unsent funnel from the authenticated dashboard. The repository atomically moves discovered, screening, ready, and retryable candidates to a terminal rejection with the reason `Cleared manually by operator`, releases their leases, and retains the audit rows so those candidates are not rediscovered. A `sending` lease is never changed because its remote Matrix outcome may already be in flight. Clearing does not change bot mode; running discovery may refill the funnel immediately.
Screening, retry promotion, dispatch, and the operator's active-queue view are ordered by the Reddit post creation time, newest first; source priority is only a tie-breaker. The dispatch worker leases the newest ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of:
- `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent;
+1
View File
@@ -64,6 +64,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
12. A growth comment interrupted while posting is rejected, never retried. The process cannot know whether the write reached Reddit, and a duplicate comment cannot be withdrawn.
13. Browsing decisions are unique per account and thing. Before selecting, the service also checks for a `simulated-upvote` by any other owned account, but account-specific rejections do not unnecessarily reserve an item globally.
14. A browsing selection is persisted as `pending` before any vote request. It becomes `submitted` only after Reddit confirms the authenticated upvote, or `failed` with the safe error reason; unconfirmed selections are never retried. Browsing rows never contribute to adaptive comment-growth promotion.
15. Manual outreach clearing is a terminal audited transition, not deletion. It rejects discovered, screening, ready, and retryable candidates, releases their leases, and never changes a `sending` row whose remote outcome may be uncertain.
10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval.
+2
View File
@@ -34,6 +34,8 @@ The Monitoring settings section owns the optional physique-targeting toggle and
Prepared cards in the authenticated outreach funnel expose the exact opener, grounded image analysis, attractiveness and physique scores, current target range, detected gender, strategy/tone/model metadata, and original source image. The live range is returned through the existing preparation field so cached dashboard bundles remain compatible during a rolling deployment. The durable queue still owns the historical preparation snapshot used for reconciliation. The source image uses the shared `OriginPostCard` privacy behavior: blurred initially, first click reveals it, and a later click opens the full-screen preview. Candidates from older persisted snapshots degrade cleanly when newer optional metadata is absent.
The funnel header also offers a destructive, confirmed **Clear funnel** action. Its dialog names the number of cancellable candidates, explains that audit rows remain and running discovery can refill the queue, and separately identifies any in-flight sends that cannot safely be cancelled. Success is reported from the authoritative API response before the funnel query refreshes.
## State model
- Remote state: TanStack Query snapshots parsed by shared Zod schemas.
+1 -1
View File
@@ -28,7 +28,7 @@ This is an ownership map, not an API dump. Read the owning implementation and te
| `services/strategy-selector.ts` | Weighted explore/exploit selection and experiment metadata | Separate image/chat strategy sections; respect mature response window and sample floor. |
| `services/recipient-deduplicator.ts` | Global recipient history plus live reservation | Case-insensitive; release failures, commit successes, recover stale claims. |
| `utils/rate-limiter.ts` | Per-account send spacing/hour limits/jitter | Increment only for confirmed sends; reply priority may use the same limiter. |
| `database/candidate-queue-repository.ts` | Durable candidate state machine, leases, retries, counts | Mutations require the expected worker lease; recover expired/interrupted work. |
| `database/candidate-queue-repository.ts` | Durable candidate state machine, leases, retries, counts, and manual clearing | Mutations require the expected worker lease; recover expired/interrupted work; manual clearing preserves sending leases and terminal audit rows. |
## Account growth
+13
View File
@@ -71,6 +71,19 @@ export const OutreachFunnelResponseSchema = z.strictObject({
candidates: z.array(OutreachCandidateSchema),
});
export const OutreachClearResponseSchema = z.strictObject({
success: z.literal(true),
cleared: z.strictObject({
discovered: z.number().int().nonnegative(),
screening: z.number().int().nonnegative(),
ready: z.number().int().nonnegative(),
retryable: z.number().int().nonnegative(),
total: z.number().int().nonnegative(),
}),
retainedSending: z.number().int().nonnegative(),
});
export type OutreachCandidateStatus = z.infer<typeof OutreachCandidateStatusSchema>;
export type OutreachCandidate = z.infer<typeof OutreachCandidateSchema>;
export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>;
export type OutreachClearResponse = z.infer<typeof OutreachClearResponseSchema>;
@@ -55,6 +55,15 @@ export interface PhysiqueTargetingReconciliation {
retained: number;
}
export interface CandidateQueueClearResult {
discovered: number;
screening: number;
ready: number;
retryable: number;
total: number;
retainedSending: number;
}
const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable'];
/** Global, durable funnel from Reddit listing discovery through outbound delivery. */
@@ -251,6 +260,34 @@ export class CandidateQueueRepository {
`).run(now, now + sendingDelayMs, now).changes;
}
/**
* Cancels every candidate that cannot already be crossing the remote-send
* boundary. Sending leases are deliberately retained because their Matrix
* outcome may be unknown while this transaction runs.
*/
clearUnsent(now: number = Date.now()): CandidateQueueClearResult {
return this.db.transaction(() => {
const statuses = ['discovered', 'screening', 'ready', 'retryable'] as const;
const counts = Object.fromEntries(statuses.map((status) => [status, 0])) as Record<(typeof statuses)[number], number>;
const rows = this.db.prepare(`
SELECT status, COUNT(*) AS count FROM candidate_queue
WHERE status IN (${statuses.map(() => '?').join(', ')})
GROUP BY status
`).all(...statuses) as Array<{ status: (typeof statuses)[number]; count: number }>;
for (const row of rows) counts[row.status] = row.count;
const retainedSending = (this.db.prepare(
`SELECT COUNT(*) AS count FROM candidate_queue WHERE status = 'sending'`,
).get() as { count: number }).count;
const changed = this.db.prepare(`
UPDATE candidate_queue
SET status = 'rejected', last_error = 'Cleared manually by operator', updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL
WHERE status IN (${statuses.map(() => '?').join(', ')})
`).run(now, ...statuses).changes;
return { ...counts, total: changed, retainedSending };
})();
}
/** Re-evaluates prepared image candidates that have not yet been leased for sending. */
reconcilePhysiqueTargeting(
targeting: PhysiqueTargeting,
+14
View File
@@ -179,6 +179,20 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
operation('getAiUsageAnalytics', 'GET', '/api/analytics/ai-usage', 'Get OpenRouter cost and model reliability', ['Analytics']),
operation('getAttention', 'GET', '/api/attention', 'Get operator attention items', ['Operations']),
operation('getOutreachFunnel', 'GET', '/api/outreach', 'Get active outreach candidates and funnel stages', ['Operations']),
operation('clearOutreachFunnel', 'DELETE', '/api/outreach', 'Cancel every unsent outreach candidate except in-flight sends', ['Operations'], {
access: 'admin',
responseSchema: z.strictObject({
success: z.literal(true),
cleared: z.strictObject({
discovered: z.number().int().nonnegative(),
screening: z.number().int().nonnegative(),
ready: z.number().int().nonnegative(),
retryable: z.number().int().nonnegative(),
total: z.number().int().nonnegative(),
}),
retainedSending: z.number().int().nonnegative(),
}),
}),
operation('getGrowthOverview', 'GET', '/api/growth', 'Get per-account comment growth state', ['Growth'], {
responseSchema: GrowthOverviewResponseSchema,
}),
+25
View File
@@ -123,5 +123,30 @@ export function createOutreachRouter(ctx: WebContext): Router {
res.json(response);
});
router.delete('/outreach', (_req, res) => {
const queue = ctx.services.candidateQueue;
if (!queue) {
res.status(503).json({ error: 'Outreach queue not initialized' });
return;
}
const result = queue.clearUnsent();
const message = `Cleared ${result.total} unsent outreach candidate(s)`
+ (result.retainedSending > 0 ? `; retained ${result.retainedSending} in-flight send(s)` : '');
ctx.activity.push('info', message);
ctx.broadcaster.push('warn', message, result);
res.json({
success: true,
cleared: {
discovered: result.discovered,
screening: result.screening,
ready: result.ready,
retryable: result.retryable,
total: result.total,
},
retainedSending: result.retainedSending,
});
});
return router;
}
+33
View File
@@ -164,6 +164,39 @@ describe('CandidateQueueRepository', () => {
expect(queue.getStats().byStatus.discovered).toBe(1);
});
it('manually clears every unsent stage while retaining an in-flight send', () => {
queue.enqueue([post('sending', 'alice')], 'image', 1, 1_000);
const sendingScreen = queue.claimForScreening('screen-sending', 1_000, 1_001)!;
queue.markReady(sendingScreen.postId, 'screen-sending', prepared(sendingScreen.post), 1_002);
queue.claimReady('account-a', 'send-worker', 1_000, 1_003);
queue.enqueue([post('retryable', 'bob')], 'image', 1, 1_004);
const retryScreen = queue.claimForScreening('screen-retry', 1_000, 1_005)!;
queue.markReady(retryScreen.postId, 'screen-retry', prepared(retryScreen.post), 1_006);
queue.claimReady('account-b', 'retry-worker', 1_000, 1_007);
queue.deferDelivery('retryable', 'retry-worker', 'rate limited', 2_000, 1_008);
queue.enqueue([post('ready', 'carol')], 'image', 1, 1_009);
const readyScreen = queue.claimForScreening('screen-ready', 1_000, 1_010)!;
queue.markReady(readyScreen.postId, 'screen-ready', prepared(readyScreen.post), 1_011);
queue.enqueue([post('screening', 'dana')], 'image', 1, 1_012);
queue.claimForScreening('screen-active', 1_000, 1_013);
queue.enqueue([post('discovered', 'erin')], 'image', 1, 1_014);
expect(queue.clearUnsent(1_020)).toEqual({
discovered: 1, screening: 1, ready: 1, retryable: 1, total: 4, retainedSending: 1,
});
expect(queue.listActive()).toEqual([
expect.objectContaining({ postId: 'sending', status: 'sending', leaseOwner: 'send-worker' }),
]);
expect(queue.markReady('screening', 'screen-active', prepared(post('screening', 'dana')), 1_021)).toBe(false);
expect(queue.markSent('sending', 'send-worker', 1_022)).toBe(true);
expect(queue.getStats()).toMatchObject({ byStatus: { rejected: 4, sent: 1 } });
const reasons = db.prepare(`SELECT DISTINCT last_error FROM candidate_queue WHERE status = 'rejected'`)
.all() as Array<{ last_error: string }>;
expect(reasons).toEqual([{ last_error: 'Cleared manually by operator' }]);
});
it('retries a rate-limited prepared candidate and preserves its generated message', () => {
queue.enqueue([post('p1', 'alice')], 'image', 1, 1_000);
const screening = queue.claimForScreening('screen', 100, 1_001)!;
+38
View File
@@ -172,6 +172,44 @@ describe('dashboard foundation', () => {
expect(sourceImage).not.toHaveClass('is-blurred');
});
it('requires confirmation before clearing unsent outreach and preserves in-flight sends', async () => {
installSuccessfulApi();
let cleared = false;
let deleteRequests = 0;
server.use(
http.get('/api/outreach', () => HttpResponse.json({
generatedAt: Date.now(),
activeCount: cleared ? 1 : 3,
stages: { discovered: cleared ? 0 : 2, screening: 0, ready: 0, sending: 1, retryable: 0 },
outcomes: { sent: 12, rejected: cleared ? 9 : 7 },
oldestActiveAt: Date.now() - 60_000,
candidates: [],
})),
http.delete('/api/outreach', () => {
deleteRequests += 1;
cleared = true;
return HttpResponse.json({
success: true,
cleared: { discovered: 2, screening: 0, ready: 0, retryable: 0, total: 2 },
retainedSending: 1,
});
}),
);
renderDashboard('/outreach');
const user = userEvent.setup();
await user.click(await screen.findByRole('button', { name: 'Clear funnel' }));
const dialog = screen.getByRole('dialog', { name: 'Clear the outreach funnel?' });
expect(dialog).toHaveTextContent('cancel 2 waiting, screening, prepared, or retrying candidates');
expect(dialog).toHaveTextContent('1 in-flight send will be left alone');
expect(deleteRequests).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Clear funnel' }));
expect(await screen.findByRole('status')).toHaveTextContent('Cleared 2 unsent candidates. 1 in-flight send was retained.');
await waitFor(() => expect(screen.getByRole('heading', { name: '1 active post' })).toBeInTheDocument());
expect(deleteRequests).toBe(1);
});
it('hydrates media filters from the routed URL', async () => {
installSuccessfulApi();
server.use(
+37 -3
View File
@@ -1,18 +1,21 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { OutreachFunnelResponseSchema } from '../../packages/api-contracts/src/index.js';
import {
OutreachClearResponseSchema,
OutreachFunnelResponseSchema,
} from '../../packages/api-contracts/src/index.js';
import type { WebContext } from '../../src/web/context.js';
import { createOutreachRouter } from '../../src/web/routes/outreach.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`);
return fetch(`http://127.0.0.1:${port}${path}`, init);
}
afterEach(async () => {
@@ -102,4 +105,35 @@ describe('outreach route', () => {
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ error: 'Outreach queue not initialized' });
});
it('clears unsent candidates, reports retained sends, and records the operator action', async () => {
const clearUnsent = vi.fn(() => ({
discovered: 2, screening: 1, ready: 3, retryable: 1, total: 7, retainedSending: 1,
}));
const activity = { push: vi.fn() };
const broadcaster = { push: vi.fn() };
const context = {
services: { candidateQueue: { clearUnsent } }, activity, broadcaster,
} as unknown as WebContext;
const app = express().use('/api', createOutreachRouter(context));
const response = await request(app, '/api/outreach', { method: 'DELETE' });
const parsed = OutreachClearResponseSchema.parse(await response.json());
expect(response.status).toBe(200);
expect(parsed).toEqual({
success: true,
cleared: { discovered: 2, screening: 1, ready: 3, retryable: 1, total: 7 },
retainedSending: 1,
});
expect(clearUnsent).toHaveBeenCalledOnce();
expect(activity.push).toHaveBeenCalledWith(
'info', 'Cleared 7 unsent outreach candidate(s); retained 1 in-flight send(s)',
);
expect(broadcaster.push).toHaveBeenCalledWith(
'warn',
'Cleared 7 unsent outreach candidate(s); retained 1 in-flight send(s)',
expect.objectContaining({ total: 7, retainedSending: 1 }),
);
});
});