Add manual outreach funnel clearing
This commit is contained in:
1 parent
6b2e1bc380
commit
4798385d8b
16 files changed
+310
-8
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';
|
import { createDashboardRequest } from '../../api/request.js';
|
||||||
|
|
||||||
export function createOutreachApi(fetchImplementation?: typeof fetch) {
|
export function createOutreachApi(fetchImplementation?: typeof fetch) {
|
||||||
@@ -16,6 +19,18 @@ export function createOutreachApi(fetchImplementation?: typeof fetch) {
|
|||||||
}
|
}
|
||||||
return OutreachFunnelResponseSchema.parse(payload);
|
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 { 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 type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
import { OriginPostCard } from '../../components/origin-post.js';
|
import { OriginPostCard } from '../../components/origin-post.js';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
|
||||||
import { outreachApi } from './outreach-api.js';
|
import { outreachApi } from './outreach-api.js';
|
||||||
|
import { ClearFunnelDialog } from './clear-funnel-dialog.js';
|
||||||
import './outreach.css';
|
import './outreach.css';
|
||||||
|
|
||||||
const STAGES: ReadonlyArray<{
|
const STAGES: ReadonlyArray<{
|
||||||
@@ -121,11 +122,17 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function OutreachPage() {
|
export function OutreachPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['outreach', 'funnel'],
|
queryKey: ['outreach', 'funnel'],
|
||||||
queryFn: outreachApi.getFunnel,
|
queryFn: outreachApi.getFunnel,
|
||||||
refetchInterval: 10_000,
|
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
|
// Single client-side clock for every displayed offset: retry countdowns tick
|
||||||
// every second while visible, everything else every 10 seconds.
|
// every second while visible, everything else every 10 seconds.
|
||||||
@@ -156,10 +163,21 @@ export function OutreachPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { data } = query;
|
const { data } = query;
|
||||||
|
const clearableCount = data.activeCount - data.stages.sending;
|
||||||
|
const clearResult = clearMutation.data;
|
||||||
return (
|
return (
|
||||||
<div className="page outreach-page">
|
<div className="page outreach-page">
|
||||||
<header className="page-header">
|
<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>
|
<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>
|
||||||
|
<div className="header-actions">
|
||||||
|
<button
|
||||||
|
className="button danger"
|
||||||
|
disabled={clearableCount === 0 || clearMutation.isPending}
|
||||||
|
onClick={() => { clearMutation.reset(); setIsConfirmingClear(true); }}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Clear funnel
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-busy={query.isFetching || undefined}
|
aria-busy={query.isFetching || undefined}
|
||||||
className="button secondary"
|
className="button secondary"
|
||||||
@@ -169,12 +187,22 @@ export function OutreachPage() {
|
|||||||
>
|
>
|
||||||
{query.isFetching ? 'Refreshing…' : 'Refresh'}
|
{query.isFetching ? 'Refreshing…' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</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">
|
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
|
||||||
<div className="runway-heading">
|
<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><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>
|
</div>
|
||||||
<ol className="outreach-stages">
|
<ol className="outreach-stages">
|
||||||
{STAGES.map((stage) => (
|
{STAGES.map((stage) => (
|
||||||
@@ -185,7 +213,7 @@ export function OutreachPage() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</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>
|
||||||
|
|
||||||
<section aria-labelledby="active-candidates-heading" className="candidate-section">
|
<section aria-labelledby="active-candidates-heading" className="candidate-section">
|
||||||
@@ -204,6 +232,16 @@ export function OutreachPage() {
|
|||||||
</ol>
|
</ol>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
.outreach-page { display: grid; gap: 1rem; }
|
.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 { 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%); }
|
.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; }
|
.runway-heading { position: relative; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.2rem; }
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ an administrator credential.
|
|||||||
| `GET` | `/api/analytics/ai-usage` | Get OpenRouter cost and model reliability | read | no |
|
| `GET` | `/api/analytics/ai-usage` | Get OpenRouter cost and model reliability | read | no |
|
||||||
| `GET` | `/api/attention` | Get operator attention items | read | no |
|
| `GET` | `/api/attention` | Get operator attention items | read | no |
|
||||||
| `GET` | `/api/outreach` | Get active outreach candidates and funnel stages | 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` | Get per-account comment growth state | read | no |
|
||||||
| `GET` | `/api/growth/{accountId}` | Get one account's growth settings, communities and comment history | 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 |
|
| `PATCH` | `/api/growth/{accountId}` | Enable, disable or re-pace comment growth for an account | admin | yes |
|
||||||
|
|||||||
@@ -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.
|
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:
|
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;
|
- `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent;
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## State model
|
||||||
|
|
||||||
- Remote state: TanStack Query snapshots parsed by shared Zod schemas.
|
- Remote state: TanStack Query snapshots parsed by shared Zod schemas.
|
||||||
|
|||||||
+1
-1
@@ -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/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. |
|
| `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. |
|
| `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
|
## Account growth
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ export const OutreachFunnelResponseSchema = z.strictObject({
|
|||||||
candidates: z.array(OutreachCandidateSchema),
|
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 OutreachCandidateStatus = z.infer<typeof OutreachCandidateStatusSchema>;
|
||||||
export type OutreachCandidate = z.infer<typeof OutreachCandidateSchema>;
|
export type OutreachCandidate = z.infer<typeof OutreachCandidateSchema>;
|
||||||
export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>;
|
export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>;
|
||||||
|
export type OutreachClearResponse = z.infer<typeof OutreachClearResponseSchema>;
|
||||||
@@ -55,6 +55,15 @@ export interface PhysiqueTargetingReconciliation {
|
|||||||
retained: number;
|
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'];
|
const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable'];
|
||||||
|
|
||||||
/** Global, durable funnel from Reddit listing discovery through outbound delivery. */
|
/** Global, durable funnel from Reddit listing discovery through outbound delivery. */
|
||||||
@@ -251,6 +260,34 @@ export class CandidateQueueRepository {
|
|||||||
`).run(now, now + sendingDelayMs, now).changes;
|
`).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. */
|
/** Re-evaluates prepared image candidates that have not yet been leased for sending. */
|
||||||
reconcilePhysiqueTargeting(
|
reconcilePhysiqueTargeting(
|
||||||
targeting: PhysiqueTargeting,
|
targeting: PhysiqueTargeting,
|
||||||
|
|||||||
@@ -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('getAiUsageAnalytics', 'GET', '/api/analytics/ai-usage', 'Get OpenRouter cost and model reliability', ['Analytics']),
|
||||||
operation('getAttention', 'GET', '/api/attention', 'Get operator attention items', ['Operations']),
|
operation('getAttention', 'GET', '/api/attention', 'Get operator attention items', ['Operations']),
|
||||||
operation('getOutreachFunnel', 'GET', '/api/outreach', 'Get active outreach candidates and funnel stages', ['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'], {
|
operation('getGrowthOverview', 'GET', '/api/growth', 'Get per-account comment growth state', ['Growth'], {
|
||||||
responseSchema: GrowthOverviewResponseSchema,
|
responseSchema: GrowthOverviewResponseSchema,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -123,5 +123,30 @@ export function createOutreachRouter(ctx: WebContext): Router {
|
|||||||
res.json(response);
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
@@ -164,6 +164,39 @@ describe('CandidateQueueRepository', () => {
|
|||||||
expect(queue.getStats().byStatus.discovered).toBe(1);
|
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', () => {
|
it('retries a rate-limited prepared candidate and preserves its generated message', () => {
|
||||||
queue.enqueue([post('p1', 'alice')], 'image', 1, 1_000);
|
queue.enqueue([post('p1', 'alice')], 'image', 1, 1_000);
|
||||||
const screening = queue.claimForScreening('screen', 100, 1_001)!;
|
const screening = queue.claimForScreening('screen', 100, 1_001)!;
|
||||||
|
|||||||
@@ -172,6 +172,44 @@ describe('dashboard foundation', () => {
|
|||||||
expect(sourceImage).not.toHaveClass('is-blurred');
|
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 () => {
|
it('hydrates media filters from the routed URL', async () => {
|
||||||
installSuccessfulApi();
|
installSuccessfulApi();
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 type { WebContext } from '../../src/web/context.js';
|
||||||
import { createOutreachRouter } from '../../src/web/routes/outreach.js';
|
import { createOutreachRouter } from '../../src/web/routes/outreach.js';
|
||||||
|
|
||||||
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
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);
|
const server = app.listen(0);
|
||||||
servers.push(server);
|
servers.push(server);
|
||||||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||||||
const { port } = server.address() as AddressInfo;
|
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 () => {
|
afterEach(async () => {
|
||||||
@@ -102,4 +105,35 @@ describe('outreach route', () => {
|
|||||||
expect(response.status).toBe(503);
|
expect(response.status).toBe(503);
|
||||||
expect(await response.json()).toEqual({ error: 'Outreach queue not initialized' });
|
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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user