Run immediate browsing sessions with enabled votes
This commit is contained in:
1 parent
643c775813
commit
540590f43e
11 files changed
+40
-19
No files matched your search
@@ -213,9 +213,9 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
|
||||
onClick={() => runNow.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{runNow.isPending ? 'Running simulation…' : 'Simulate session now'}
|
||||
{runNow.isPending ? 'Running session…' : 'Run session now'}
|
||||
</button>
|
||||
{!settings.enabled ? <p className="field-hint">Enable browsing sessions to run a simulation now.</p> : null}
|
||||
{!settings.enabled ? <p className="field-hint">Enable browsing sessions to run one now.</p> : null}
|
||||
<dl className="growth-metrics">
|
||||
<div><dt>Selected today</dt><dd>{stats.selected} / {todayTarget}</dd></div>
|
||||
<div><dt>Posts</dt><dd>{stats.posts}</dd></div>
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ an administrator credential.
|
||||
| `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}/browsing` | Configure per-account browsing selection and automatic upvotes | admin | yes |
|
||||
| `POST` | `/api/growth/{accountId}/browsing/run` | Run one simulation-only browsing session immediately | admin | yes |
|
||||
| `POST` | `/api/growth/{accountId}/browsing/run` | Run one browsing session immediately | admin | yes |
|
||||
| `POST` | `/api/growth/{accountId}/pause` | Stop an account from posting further comments | control | yes |
|
||||
| `POST` | `/api/growth/{accountId}/resume` | Clear a growth pause after reviewing the cause | admin | yes |
|
||||
| `DELETE` | `/api/growth/{accountId}/queue` | Clear queued and retryable growth threads that have not been posted | admin | yes |
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ Do not add local retries around this layer without preserving the shared schedul
|
||||
|
||||
After reply handling, response checks, and comment-growth work, an enabled account may run one due browsing session. The durable `next_due_at` gate survives restarts; a deterministic per-account daily target and randomized waking-hours spacing avoid recalculating a flapping schedule on each minute tick. The session picks one configured growth community by weight, reads its hot listing through the shared Reddit request coordinator, optionally reads typed top-level comments, applies deterministic exclusions, and only then selects randomly.
|
||||
|
||||
The terminal operation records the selection in SQLite before any remote write, so an interrupted or uncertain vote is never retried. With the separate automatic-upvote setting enabled, running mode sends one authenticated Reddit `api/vote` request through the same browser transport and shared coordinator as growth comments, then marks the audit record submitted only after Reddit confirms it. Scan mode and browser dry-run always record simulation-only selections; stopped mode performs no discovery or decision work. Reddit 429 responses extend the shared cooldown, while ordinary read failures remain isolated from replies and comment growth. An operator can also run one immediate session for an enabled, running account: it bypasses the due, quiet-hours, and daily-target gates but preserves content exclusions and always disables vote submission.
|
||||
The terminal operation records the selection in SQLite before any remote write, so an interrupted or uncertain vote is never retried. With the separate automatic-upvote setting enabled, running mode sends one authenticated Reddit `api/vote` request through the same browser transport and shared coordinator as growth comments, then marks the audit record submitted only after Reddit confirms it. Scan mode and browser dry-run always record simulation-only selections; stopped mode performs no discovery or decision work. Reddit 429 responses extend the shared cooldown, while ordinary read failures remain isolated from replies and comment growth. An operator can also run one immediate session for an enabled, running account: it bypasses the due, quiet-hours, and daily-target gates while preserving content exclusions and follows the same automatic-upvote and dry-run rules.
|
||||
|
||||
## Reply-first conversation automation
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ Navigation is declared in `app/navigation.ts`; route elements are composed in `a
|
||||
|
||||
The account-growth page states plainly that comments are AI-written and posted without review, shows the adaptive allowance and trust level rather than a settable rate, and surfaces every posted comment's exact text alongside its score and removal state. A paused account replaces the pause control with a resume control and explains why it stopped, because resuming is the one action that can compound the damage.
|
||||
|
||||
Its browsing section separates session scheduling from the default-off automatic-upvote switch, shows whether each selection was submitted or simulated, today's deterministic target, post/comment split, exclusion counts, durable next session, and recent selections with Reddit permalinks. Scan mode and browser dry-run remain simulation-only. An enabled, running account can also run one immediate simulation-only selection; it bypasses the schedule but never submits a vote. Enabling browsing does not enable comment growth or bot processing.
|
||||
Its browsing section separates session scheduling from the default-off automatic-upvote switch, shows whether each selection was submitted or simulated, today's deterministic target, post/comment split, exclusion counts, durable next session, and recent selections with Reddit permalinks. Scan mode and browser dry-run remain simulation-only. An enabled, running account can also run one immediate selection; it bypasses the schedule and follows the same automatic-upvote setting. Enabling browsing does not enable comment growth or bot processing.
|
||||
|
||||
The Monitoring settings section owns the optional physique-targeting toggle and inclusive 1–10 range. It explains that the signal is subjective, applies only to image posts, and screens out unscorable images while enabled.
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface AccountBrowsingSimulationServiceOptions {
|
||||
};
|
||||
sink: BrowsingDecisionSink;
|
||||
voter?: BrowsingVoter;
|
||||
/** Browser dry-run is an absolute no-write boundary, including manual runs. */
|
||||
dryRun?: boolean;
|
||||
logger: Logger;
|
||||
now?: () => number;
|
||||
random?: () => number;
|
||||
@@ -55,9 +57,9 @@ export class AccountBrowsingSimulationService {
|
||||
this.random = options.random ?? Math.random;
|
||||
}
|
||||
|
||||
/** Runs one operator-requested, simulation-only selection without waiting for the schedule. */
|
||||
/** Runs one operator-requested selection without waiting for the schedule. */
|
||||
runNow(signal?: AbortSignal): Promise<BrowsingSimulationOutcome> {
|
||||
return this.tick({ force: true }, signal);
|
||||
return this.tick({ allowVoting: !this.options.dryRun, force: true }, signal);
|
||||
}
|
||||
|
||||
async tick(
|
||||
@@ -120,7 +122,7 @@ export class AccountBrowsingSimulationService {
|
||||
const selected = candidates[Math.floor(this.random() * candidates.length)];
|
||||
const decision = toDecision(this.options.accountId, selected, 'simulated-upvote', 'Eligible browsing selection', sessionId, now);
|
||||
await this.options.sink.recordSimulation(decision);
|
||||
if (settings.autoVoteEnabled && allowVoting && this.options.voter) {
|
||||
if (settings.autoVoteEnabled && allowVoting && !this.options.dryRun && this.options.voter) {
|
||||
const result = await this.options.voter.upvote(selected.fullname);
|
||||
if (result.status === 'submitted') {
|
||||
this.options.repository.markVoteSubmitted(this.options.accountId, selected.fullname);
|
||||
|
||||
@@ -299,6 +299,7 @@ async function buildRuntime(
|
||||
},
|
||||
sink: browsingRepository,
|
||||
voter: commentClient,
|
||||
dryRun: accountConfig.browser.dryRun,
|
||||
logger,
|
||||
onActivity: (decision) => activityLogRepo.insert({
|
||||
timestamp: decision.decidedAt,
|
||||
|
||||
@@ -186,7 +186,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
||||
requestSchema: BrowsingSettingsUpdateRequestSchema,
|
||||
responseSchema: GrowthAccountResponseSchema,
|
||||
}),
|
||||
operation('runBrowsingSimulation', 'POST', '/api/growth/{accountId}/browsing/run', 'Run one simulation-only browsing session immediately', ['Growth'], {
|
||||
operation('runBrowsingSimulation', 'POST', '/api/growth/{accountId}/browsing/run', 'Run one browsing session immediately', ['Growth'], {
|
||||
access: 'admin',
|
||||
responseSchema: GrowthAccountResponseSchema,
|
||||
}),
|
||||
|
||||
@@ -107,7 +107,7 @@ export function createGrowthRouter(ctx: WebContext): Router {
|
||||
} catch (error) {
|
||||
return res.status(502).json({ error: `Browsing simulation failed: ${errorMessage(error)}` });
|
||||
}
|
||||
ctx.broadcaster.push('info', `Browsing simulation run manually for u/${username}; no Reddit vote was submitted`);
|
||||
ctx.broadcaster.push('info', `Browsing session run manually for u/${username}`);
|
||||
return res.json({ success: true, account: detail(repo, browsingRepo, accountId, username) });
|
||||
});
|
||||
|
||||
|
||||
@@ -143,16 +143,34 @@ describe('AccountBrowsingSimulationService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs a requested simulation immediately without submitting a vote', async () => {
|
||||
enable({ postWeight: 100, commentWeight: 0 });
|
||||
const fixture = service();
|
||||
it('runs a requested session immediately and follows the automatic-vote setting', async () => {
|
||||
enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true });
|
||||
const voter = { upvote: vi.fn().mockResolvedValue({ status: 'submitted' }) };
|
||||
const fixture = service({ voter });
|
||||
|
||||
const result = await fixture.instance.runNow();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
kind: 'simulated', decision: { thingFullname: 't3_one', voteSubmitted: false },
|
||||
kind: 'simulated', decision: { thingFullname: 't3_one', voteSubmitted: true },
|
||||
});
|
||||
expect(fixture.getHotPosts).toHaveBeenCalledOnce();
|
||||
expect(voter.upvote).toHaveBeenCalledWith('t3_one');
|
||||
});
|
||||
|
||||
it('keeps an immediate session simulation-only in browser dry-run', async () => {
|
||||
enable({ postWeight: 100, commentWeight: 0, autoVoteEnabled: true });
|
||||
const voter = { upvote: vi.fn().mockResolvedValue({ status: 'submitted' }) };
|
||||
const fixture = service({ voter });
|
||||
fixture.instance = new AccountBrowsingSimulationService({
|
||||
accountId: ACCOUNT, repository, growthSubreddits: () => [{ subreddit: 'AskUK', weight: 1 }] as never,
|
||||
reddit: { getHotPosts: fixture.getHotPosts, getBrowsingComments: fixture.getBrowsingComments },
|
||||
sink: repository, voter, logger: createMockLogger(), now: () => NOW, random: () => 0, dryRun: true,
|
||||
});
|
||||
|
||||
await expect(fixture.instance.runNow()).resolves.toMatchObject({
|
||||
kind: 'simulated', decision: { voteSubmitted: false },
|
||||
});
|
||||
expect(voter.upvote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('honours comment weighting when eligible comments exist', async () => {
|
||||
|
||||
@@ -240,7 +240,7 @@ describe('GrowthPage', () => {
|
||||
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
|
||||
method: 'PATCH', body: { enabled: true },
|
||||
})));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Simulate session now' })).toBeEnabled());
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Run session now' })).toBeEnabled());
|
||||
expect(requests[0].url).toContain('/api/growth/%40t2_grower%3Areddit.com/browsing');
|
||||
await user.click(screen.getByLabelText('Submit selected upvotes'));
|
||||
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
|
||||
@@ -248,7 +248,7 @@ describe('GrowthPage', () => {
|
||||
})));
|
||||
});
|
||||
|
||||
it('runs an enabled browsing simulation immediately', async () => {
|
||||
it('runs an enabled browsing session immediately', async () => {
|
||||
const currentBrowsing = browsing(true);
|
||||
detailPayload = detail({
|
||||
browsing: {
|
||||
@@ -259,7 +259,7 @@ describe('GrowthPage', () => {
|
||||
renderGrowth();
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Simulate session now' }));
|
||||
await user.click(await screen.findByRole('button', { name: 'Run session now' }));
|
||||
|
||||
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
|
||||
method: 'POST', body: null,
|
||||
|
||||
@@ -290,14 +290,14 @@ describe('growth route', () => {
|
||||
});
|
||||
|
||||
describe('POST /api/growth/:accountId/browsing/run', () => {
|
||||
it('runs one immediate simulation through the active account runtime', async () => {
|
||||
it('runs one immediate session through the active account runtime', async () => {
|
||||
const { status, json } = await call(app, 'POST', `/api/growth/${ENCODED}/browsing/run`);
|
||||
|
||||
expect(status).toBe(200);
|
||||
GrowthAccountResponseSchema.parse(json);
|
||||
expect(runNow).toHaveBeenCalledOnce();
|
||||
expect(pushed).toContainEqual({
|
||||
level: 'info', message: 'Browsing simulation run manually for u/grower; no Reddit vote was submitted',
|
||||
level: 'info', message: 'Browsing session run manually for u/grower',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user