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

@@ -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;
}