Remove browsing selection ceilings
This commit is contained in:
1 parent
c028637556
commit
bc42c55555
14 files changed
+81
-53
No files matched your search
@@ -193,10 +193,10 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
|
|||||||
const dailyRangeValid = Number.isInteger(parsedDailyMinimum)
|
const dailyRangeValid = Number.isInteger(parsedDailyMinimum)
|
||||||
&& Number.isInteger(parsedDailyMaximum)
|
&& Number.isInteger(parsedDailyMaximum)
|
||||||
&& parsedDailyMinimum >= 1
|
&& parsedDailyMinimum >= 1
|
||||||
&& parsedDailyMaximum <= 12
|
&& parsedDailyMaximum >= 1
|
||||||
&& parsedDailyMinimum <= parsedDailyMaximum;
|
&& parsedDailyMinimum <= parsedDailyMaximum;
|
||||||
const dailyRangeError = dailyMinimum !== '' && dailyMaximum !== '' && !dailyRangeValid
|
const dailyRangeError = dailyMinimum !== '' && dailyMaximum !== '' && !dailyRangeValid
|
||||||
? 'Use a range from 1 to 12, with the minimum no greater than the maximum.'
|
? 'Use positive whole numbers, with the minimum no greater than the maximum.'
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -233,7 +233,6 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
|
|||||||
hint="The system chooses one stable target inside this range for each account and local day."
|
hint="The system chooses one stable target inside this range for each account and local day."
|
||||||
label="Daily minimum"
|
label="Daily minimum"
|
||||||
min={1}
|
min={1}
|
||||||
max={12}
|
|
||||||
name="browsing-daily-minimum"
|
name="browsing-daily-minimum"
|
||||||
onChange={setDailyMinimum}
|
onChange={setDailyMinimum}
|
||||||
suffix="/day"
|
suffix="/day"
|
||||||
@@ -243,7 +242,6 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
|
|||||||
error={dailyRangeError}
|
error={dailyRangeError}
|
||||||
label="Daily maximum"
|
label="Daily maximum"
|
||||||
min={1}
|
min={1}
|
||||||
max={12}
|
|
||||||
name="browsing-daily-maximum"
|
name="browsing-daily-maximum"
|
||||||
onChange={setDailyMaximum}
|
onChange={setDailyMaximum}
|
||||||
suffix="/day"
|
suffix="/day"
|
||||||
@@ -291,7 +289,6 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
|
|||||||
hint="Maximum eligible posts or comments selected by each scheduled or manual session."
|
hint="Maximum eligible posts or comments selected by each scheduled or manual session."
|
||||||
label="Selections per session"
|
label="Selections per session"
|
||||||
min={1}
|
min={1}
|
||||||
max={12}
|
|
||||||
name="browsing-selections-per-session"
|
name="browsing-selections-per-session"
|
||||||
onChange={setSelectionsPerSession}
|
onChange={setSelectionsPerSession}
|
||||||
suffix="/session"
|
suffix="/session"
|
||||||
@@ -302,7 +299,7 @@ function BrowsingSimulationSection({ account }: { account: GrowthAccountDetail }
|
|||||||
disabled={update.isPending}
|
disabled={update.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const parsed = Number.parseInt(selectionsPerSession, 10);
|
const parsed = Number.parseInt(selectionsPerSession, 10);
|
||||||
if (Number.isInteger(parsed) && parsed >= 1 && parsed <= 12) {
|
if (Number.isSafeInteger(parsed) && parsed >= 1) {
|
||||||
update.mutate({ selectionsPerSession: parsed });
|
update.mutate({ selectionsPerSession: parsed });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ A per-community `dailyCap` only distributes the account allowance; caps are not
|
|||||||
|
|
||||||
Community weights feed a new weighted-random pick for each discovered growth thread. The grower queues only one new thread at a time, drains due retries and existing queue work before polling another community, and exposes an account-scoped queue clear that removes only unposted discovered/retryable rows.
|
Community weights feed a new weighted-random pick for each discovered growth thread. The grower queues only one new thread at a time, drains due retries and existing queue work before polling another community, and exposes an account-scoped queue clear that removes only unposted discovered/retryable rows.
|
||||||
|
|
||||||
Browsing sessions are configured separately per account through `/api/growth/{accountId}/browsing` and reuse the enabled growth-community list and weights. Sessions default off with a configurable 3–6 selections-per-local-day range and a 60/40 post/comment split, respect quiet hours, exclude NSFW unless enabled, and have a non-overridable 12/day ceiling. The service derives one stable target inside the configured daily range for each account and local day. The operator also chooses 1–12 selections per session and a minimum gap of at least 45 minutes; the service adds a random delay of up to `jitterMs` on top before persisting the next due time. A scheduled session is capped by the remaining daily target, while a manually requested session bypasses that target but remains capped by the remaining hard daily ceiling. `autoVoteEnabled` is a separate, default-off operator setting; it can submit selected upvotes only in running mode when browser dry-run is off. `nextDueAt` is service-owned durable state and cannot be set through the API.
|
Browsing sessions are configured separately per account through `/api/growth/{accountId}/browsing` and reuse the enabled growth-community list and weights. Sessions default off with a configurable 3–6 selections-per-local-day range and a 60/40 post/comment split, respect quiet hours, and exclude NSFW unless enabled. The daily minimum, daily maximum, and selections per session accept positive whole numbers without a product-imposed upper ceiling; the maximum must not be lower than the minimum. The service derives one stable target inside the configured daily range for each account and local day. The operator also chooses a minimum gap of at least 45 minutes, and the service adds a random delay of up to `jitterMs` on top before persisting the next due time. A scheduled session is capped by the remaining daily target, while a manually requested session bypasses that target and uses the configured session size. `autoVoteEnabled` is a separate, default-off operator setting; it can submit selected upvotes only in running mode when browser dry-run is off. `nextDueAt` is service-owned durable state and cannot be set through the API.
|
||||||
|
|
||||||
## Runtime-editable subset
|
## Runtime-editable subset
|
||||||
|
|
||||||
|
|||||||
+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; each completed attempt schedules the next one at the operator's minimum gap plus bounded random jitter, moved past quiet hours when necessary. 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 up to the configured session size without replacement. Scheduled sessions cannot select beyond the remaining deterministic daily target.
|
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; each completed attempt schedules the next one at the operator's minimum gap plus bounded random jitter, moved past quiet hours when necessary. 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 up to the configured session size without replacement. Scheduled sessions cannot select beyond the remaining deterministic daily target.
|
||||||
|
|
||||||
Each terminal selection is recorded as pending in SQLite before its remote write, so an interrupted or uncertain vote is never retried. With the separate automatic-upvote setting enabled, running mode sends authenticated Reddit `api/vote` requests sequentially through the same browser transport and shared coordinator as growth comments, then marks each audit record `submitted` only after Reddit confirms it; safe failures become `failed`. Scan mode and browser dry-run record `simulated` 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 normal daily-target gates, uses the configured session size up to the remaining hard 12/day ceiling, preserves content exclusions, and follows the same automatic-upvote and dry-run rules.
|
Each terminal selection is recorded as pending in SQLite before its remote write, so an interrupted or uncertain vote is never retried. With the separate automatic-upvote setting enabled, running mode sends authenticated Reddit `api/vote` requests sequentially through the same browser transport and shared coordinator as growth comments, then marks each audit record `submitted` only after Reddit confirms it; safe failures become `failed`. Scan mode and browser dry-run record `simulated` 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 normal daily-target gates, uses the configured session size, preserves content exclusions, and follows the same automatic-upvote and dry-run rules.
|
||||||
|
|
||||||
## Reply-first conversation automation
|
## Reply-first conversation automation
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ The application uses one `better-sqlite3` database, normally `data/reddit-bot.db
|
|||||||
- `account_growth_settings`: per-account commenting switch, adaptive trust level, optional operator daily-limit override (`daily_limit`, null means adaptive), spacing/jitter, quiet hours, NSFW opt-in, and the operator-clearable pause reason.
|
- `account_growth_settings`: per-account commenting switch, adaptive trust level, optional operator daily-limit override (`daily_limit`, null means adaptive), spacing/jitter, quiet hours, NSFW opt-in, and the operator-clearable pause reason.
|
||||||
- `account_growth_subreddits`: per-account community list with weight, daily cap, and any Reddit-imposed disable reason or cooldown.
|
- `account_growth_subreddits`: per-account community list with weight, daily cap, and any Reddit-imposed disable reason or cooldown.
|
||||||
- `account_growth_comments`: durable growth comment records from discovery through posting and verification, including the exact text posted, score history, removal and public-visibility verdicts.
|
- `account_growth_comments`: durable growth comment records from discovery through posting and verification, including the exact text posted, score history, removal and public-visibility verdicts.
|
||||||
- `account_browsing_settings`: disabled-by-default per-account browsing policy, conservative daily range, selections per session, post/comment weights, quiet hours, minimum spacing plus jitter, NSFW policy, default-off automatic-upvote setting, and durable next due time.
|
- `account_browsing_settings`: disabled-by-default per-account browsing policy, positive whole-number daily range and selections per session without a product ceiling, post/comment weights, quiet hours, minimum spacing plus jitter, NSFW policy, default-off automatic-upvote setting, and durable next due time.
|
||||||
- `account_browsing_decisions`: minimal post/comment identity, permalink, decision/reason, durable vote status (`simulated`, `pending`, `submitted`, or `failed`), and scheduled-session audit. Reddit text is deliberately not retained.
|
- `account_browsing_decisions`: minimal post/comment identity, permalink, decision/reason, durable vote status (`simulated`, `pending`, `submitted`, or `failed`), and scheduled-session audit. Reddit text is deliberately not retained.
|
||||||
|
|
||||||
The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID.
|
The special legacy-unassigned account exists only to preserve rows created before account ownership was introduced. New runtime data must use a verified account ID.
|
||||||
|
|||||||
+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.
|
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, lets the operator set the daily target range, minimum gap, and selections per session, and 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. The daily range is saved atomically; changing it recalculates today's target, which then remains stable for that range and local day. The displayed minimum gap is followed by bounded random jitter. Scan mode and browser dry-run remain simulation-only. An enabled, running account can also run one immediate session at the configured size, subject to the hard daily ceiling; it bypasses the schedule and follows the same automatic-upvote setting. Enabling browsing does not enable comment growth or bot processing.
|
Its browsing section separates session scheduling from the default-off automatic-upvote switch, lets the operator set the daily target range, minimum gap, and selections per session, and 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. Daily and per-session counts have no product-imposed upper ceiling. The daily range is saved atomically; changing it recalculates today's target, which then remains stable for that range and local day. The displayed minimum gap is followed by bounded random jitter. Scan mode and browser dry-run remain simulation-only. An enabled, running account can also run one immediate session at the configured size; 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -157,14 +157,14 @@ export const GrowthSettingsUpdateRequestSchema = z.strictObject({
|
|||||||
|
|
||||||
export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
|
export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
dailyTargetMin: z.number().int().min(1).max(12).optional(),
|
dailyTargetMin: z.number().int().min(1).optional(),
|
||||||
dailyTargetMax: z.number().int().min(1).max(12).optional(),
|
dailyTargetMax: z.number().int().min(1).optional(),
|
||||||
postWeight: z.number().min(0).max(100).optional(),
|
postWeight: z.number().min(0).max(100).optional(),
|
||||||
commentWeight: z.number().min(0).max(100).optional(),
|
commentWeight: z.number().min(0).max(100).optional(),
|
||||||
quietHours: GrowthQuietHoursSchema.optional(),
|
quietHours: GrowthQuietHoursSchema.optional(),
|
||||||
minimumGapMs: z.number().int().min(45 * 60_000).max(24 * 60 * 60_000).optional(),
|
minimumGapMs: z.number().int().min(45 * 60_000).max(24 * 60 * 60_000).optional(),
|
||||||
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
|
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
|
||||||
selectionsPerSession: z.number().int().min(1).max(12).optional(),
|
selectionsPerSession: z.number().int().min(1).optional(),
|
||||||
allowNsfw: z.boolean().optional(),
|
allowNsfw: z.boolean().optional(),
|
||||||
autoVoteEnabled: z.boolean().optional(),
|
autoVoteEnabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -261,12 +261,12 @@ export class AccountBrowsingSimulationRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateSettings(settings: AccountBrowsingSettingsRecord): void {
|
function validateSettings(settings: AccountBrowsingSettingsRecord): void {
|
||||||
if (!Number.isInteger(settings.dailyTargetMin) || settings.dailyTargetMin < 1 || settings.dailyTargetMin > 12) {
|
if (!Number.isSafeInteger(settings.dailyTargetMin) || settings.dailyTargetMin < 1) {
|
||||||
throw new Error('Browsing minimum daily target must be a whole number between 1 and 12');
|
throw new Error('Browsing minimum daily target must be a positive whole number');
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(settings.dailyTargetMax)
|
if (!Number.isSafeInteger(settings.dailyTargetMax)
|
||||||
|| settings.dailyTargetMax < settings.dailyTargetMin || settings.dailyTargetMax > 12) {
|
|| settings.dailyTargetMax < settings.dailyTargetMin) {
|
||||||
throw new Error('Browsing maximum daily target must be between the minimum and 12');
|
throw new Error('Browsing maximum daily target must be a whole number no lower than the minimum');
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(settings.postWeight) || settings.postWeight < 0
|
if (!Number.isFinite(settings.postWeight) || settings.postWeight < 0
|
||||||
|| !Number.isFinite(settings.commentWeight) || settings.commentWeight < 0
|
|| !Number.isFinite(settings.commentWeight) || settings.commentWeight < 0
|
||||||
@@ -279,9 +279,8 @@ function validateSettings(settings: AccountBrowsingSettingsRecord): void {
|
|||||||
if (!Number.isInteger(settings.jitterMs) || settings.jitterMs < 0) {
|
if (!Number.isInteger(settings.jitterMs) || settings.jitterMs < 0) {
|
||||||
throw new Error('Browsing jitter must be a non-negative whole number');
|
throw new Error('Browsing jitter must be a non-negative whole number');
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(settings.selectionsPerSession)
|
if (!Number.isSafeInteger(settings.selectionsPerSession) || settings.selectionsPerSession < 1) {
|
||||||
|| settings.selectionsPerSession < 1 || settings.selectionsPerSession > 12) {
|
throw new Error('Browsing selections per session must be a positive whole number');
|
||||||
throw new Error('Browsing selections per session must be a whole number between 1 and 12');
|
|
||||||
}
|
}
|
||||||
const { startHour, endHour } = settings.quietHours;
|
const { startHour, endHour } = settings.quietHours;
|
||||||
if (!Number.isInteger(startHour) || startHour < 0 || startHour > 23
|
if (!Number.isInteger(startHour) || startHour < 0 || startHour > 23
|
||||||
|
|||||||
@@ -619,6 +619,40 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
() => {
|
() => {
|
||||||
addColumnIfNotExists(db, 'account_browsing_settings', 'selections_per_session', 'INTEGER NOT NULL DEFAULT 1 CHECK(selections_per_session BETWEEN 1 AND 12)');
|
addColumnIfNotExists(db, 'account_browsing_settings', 'selections_per_session', 'INTEGER NOT NULL DEFAULT 1 CHECK(selections_per_session BETWEEN 1 AND 12)');
|
||||||
},
|
},
|
||||||
|
() => db.exec(`
|
||||||
|
CREATE TABLE account_browsing_settings_without_ceilings (
|
||||||
|
account_id TEXT PRIMARY KEY,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0 CHECK(enabled IN (0, 1)),
|
||||||
|
daily_target_min INTEGER NOT NULL DEFAULT 3 CHECK(daily_target_min >= 1),
|
||||||
|
daily_target_max INTEGER NOT NULL DEFAULT 6 CHECK(daily_target_max >= daily_target_min),
|
||||||
|
post_weight INTEGER NOT NULL DEFAULT 60,
|
||||||
|
comment_weight INTEGER NOT NULL DEFAULT 40,
|
||||||
|
quiet_hours_json TEXT NOT NULL DEFAULT '{"enabled":true,"startHour":0,"endHour":7}',
|
||||||
|
minimum_gap_ms INTEGER NOT NULL DEFAULT 2700000,
|
||||||
|
jitter_ms INTEGER NOT NULL DEFAULT 900000,
|
||||||
|
allow_nsfw INTEGER NOT NULL DEFAULT 0 CHECK(allow_nsfw IN (0, 1)),
|
||||||
|
next_due_at INTEGER,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
auto_vote_enabled INTEGER NOT NULL DEFAULT 0 CHECK(auto_vote_enabled IN (0, 1)),
|
||||||
|
selections_per_session INTEGER NOT NULL DEFAULT 1 CHECK(selections_per_session >= 1),
|
||||||
|
FOREIGN KEY (account_id) REFERENCES reddit_accounts(account_id) ON DELETE CASCADE,
|
||||||
|
CHECK(post_weight >= 0 AND comment_weight >= 0 AND post_weight + comment_weight > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO account_browsing_settings_without_ceilings (
|
||||||
|
account_id, enabled, daily_target_min, daily_target_max, post_weight,
|
||||||
|
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw,
|
||||||
|
next_due_at, updated_at, auto_vote_enabled, selections_per_session
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
account_id, enabled, daily_target_min, daily_target_max, post_weight,
|
||||||
|
comment_weight, quiet_hours_json, minimum_gap_ms, jitter_ms, allow_nsfw,
|
||||||
|
next_due_at, updated_at, auto_vote_enabled, selections_per_session
|
||||||
|
FROM account_browsing_settings;
|
||||||
|
|
||||||
|
DROP TABLE account_browsing_settings;
|
||||||
|
ALTER TABLE account_browsing_settings_without_ceilings RENAME TO account_browsing_settings;
|
||||||
|
`),
|
||||||
];
|
];
|
||||||
|
|
||||||
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type { Logger } from '../utils/logger.js';
|
|||||||
const MAX_CONTENT_AGE_MS = 7 * 24 * 60 * 60_000;
|
const MAX_CONTENT_AGE_MS = 7 * 24 * 60 * 60_000;
|
||||||
const MIN_POST_CONTEXT = 12;
|
const MIN_POST_CONTEXT = 12;
|
||||||
const MIN_COMMENT_CONTEXT = 20;
|
const MIN_COMMENT_CONTEXT = 20;
|
||||||
const MAX_DAILY_SELECTIONS = 12;
|
|
||||||
|
|
||||||
export interface BrowsingDecisionSink {
|
export interface BrowsingDecisionSink {
|
||||||
recordSimulation(decision: BrowsingDecisionRecord): Promise<void>;
|
recordSimulation(decision: BrowsingDecisionRecord): Promise<void>;
|
||||||
@@ -95,11 +94,6 @@ export class AccountBrowsingSimulationService {
|
|||||||
const today = startOfLocalDay(now);
|
const today = startOfLocalDay(now);
|
||||||
const target = dailyTarget(this.options.accountId, today, settings);
|
const target = dailyTarget(this.options.accountId, today, settings);
|
||||||
const selectedToday = this.options.repository.countSelectedSince(this.options.accountId, today);
|
const selectedToday = this.options.repository.countSelectedSince(this.options.accountId, today);
|
||||||
if (selectedToday >= MAX_DAILY_SELECTIONS) {
|
|
||||||
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
|
|
||||||
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
|
|
||||||
return { kind: 'scheduled', nextDueAt };
|
|
||||||
}
|
|
||||||
if (!force && selectedToday >= target) {
|
if (!force && selectedToday >= target) {
|
||||||
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
|
const nextDueAt = moveOutsideQuietHours(today + 24 * 60 * 60_000, settings);
|
||||||
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
|
this.options.repository.setNextDueAt(this.options.accountId, nextDueAt, now);
|
||||||
@@ -134,7 +128,7 @@ export class AccountBrowsingSimulationService {
|
|||||||
if (candidates.length === 0 && fallbackCandidates.length === 0) return { kind: 'scheduled', nextDueAt };
|
if (candidates.length === 0 && fallbackCandidates.length === 0) return { kind: 'scheduled', nextDueAt };
|
||||||
|
|
||||||
const sessionLimit = force
|
const sessionLimit = force
|
||||||
? Math.min(settings.selectionsPerSession, MAX_DAILY_SELECTIONS - selectedToday)
|
? settings.selectionsPerSession
|
||||||
: Math.min(settings.selectionsPerSession, target - selectedToday);
|
: Math.min(settings.selectionsPerSession, target - selectedToday);
|
||||||
const selected = pickRandom(candidates, sessionLimit, this.random);
|
const selected = pickRandom(candidates, sessionLimit, this.random);
|
||||||
if (selected.length < sessionLimit) {
|
if (selected.length < sessionLimit) {
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ export const GrowthQuietHoursSchema = z.strictObject({
|
|||||||
|
|
||||||
export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
|
export const BrowsingSettingsUpdateRequestSchema = z.strictObject({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
dailyTargetMin: z.number().int().min(1).max(12).optional(),
|
dailyTargetMin: z.number().int().min(1).optional(),
|
||||||
dailyTargetMax: z.number().int().min(1).max(12).optional(),
|
dailyTargetMax: z.number().int().min(1).optional(),
|
||||||
postWeight: z.number().min(0).max(100).optional(),
|
postWeight: z.number().min(0).max(100).optional(),
|
||||||
commentWeight: z.number().min(0).max(100).optional(),
|
commentWeight: z.number().min(0).max(100).optional(),
|
||||||
quietHours: GrowthQuietHoursSchema.optional(),
|
quietHours: GrowthQuietHoursSchema.optional(),
|
||||||
minimumGapMs: z.number().int().min(45 * 60_000).max(DAY_MS).optional(),
|
minimumGapMs: z.number().int().min(45 * 60_000).max(DAY_MS).optional(),
|
||||||
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
|
jitterMs: z.number().int().min(0).max(6 * 60 * 60_000).optional(),
|
||||||
selectionsPerSession: z.number().int().min(1).max(12).optional(),
|
selectionsPerSession: z.number().int().min(1).optional(),
|
||||||
allowNsfw: z.boolean().optional(),
|
allowNsfw: z.boolean().optional(),
|
||||||
autoVoteEnabled: z.boolean().optional(),
|
autoVoteEnabled: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -157,16 +157,17 @@ describe('AccountBrowsingSimulationService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('selects the configured number of distinct things in one session', async () => {
|
it('selects the configured number of distinct things in one session', async () => {
|
||||||
enable({ postWeight: 100, commentWeight: 0, selectionsPerSession: 3 });
|
const posts = Array.from({ length: 18 }, (_, index) => post(`item-${index}`));
|
||||||
const fixture = service({ posts: [post('one'), post('two'), post('three'), post('four')] });
|
enable({ postWeight: 100, commentWeight: 0, selectionsPerSession: 15 });
|
||||||
|
const fixture = service({ posts });
|
||||||
|
|
||||||
const result = await fixture.instance.runNow();
|
const result = await fixture.instance.runNow();
|
||||||
|
|
||||||
expect(result).toMatchObject({ kind: 'simulated' });
|
expect(result).toMatchObject({ kind: 'simulated' });
|
||||||
if (result.kind !== 'simulated') throw new Error('Expected a simulated session');
|
if (result.kind !== 'simulated') throw new Error('Expected a simulated session');
|
||||||
expect(result.decisions).toHaveLength(3);
|
expect(result.decisions).toHaveLength(15);
|
||||||
expect(new Set(result.decisions.map((decision) => decision.thingFullname)).size).toBe(3);
|
expect(new Set(result.decisions.map((decision) => decision.thingFullname)).size).toBe(15);
|
||||||
expect(repository.listRecentSelections(ACCOUNT)).toHaveLength(3);
|
expect(repository.listRecentSelections(ACCOUNT)).toHaveLength(15);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps a scheduled multi-selection session at the remaining daily target', async () => {
|
it('caps a scheduled multi-selection session at the remaining daily target', async () => {
|
||||||
@@ -335,7 +336,7 @@ describe('browsing migration', () => {
|
|||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
const version = db.pragma('user_version', { simple: true }) as number;
|
const version = db.pragma('user_version', { simple: true }) as number;
|
||||||
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
db.exec('DROP TABLE account_browsing_decisions; DROP TABLE account_browsing_settings;');
|
||||||
db.pragma(`user_version = ${version - 4}`);
|
db.pragma(`user_version = ${version - 5}`);
|
||||||
|
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
insertAccount(db, ACCOUNT, 'owner');
|
insertAccount(db, ACCOUNT, 'owner');
|
||||||
@@ -343,6 +344,9 @@ describe('browsing migration', () => {
|
|||||||
expect(new AccountBrowsingSimulationRepository(db).getSettings(ACCOUNT)).toMatchObject({
|
expect(new AccountBrowsingSimulationRepository(db).getSettings(ACCOUNT)).toMatchObject({
|
||||||
enabled: false, dailyTargetMin: 3, dailyTargetMax: 6, selectionsPerSession: 1, nextDueAt: null,
|
enabled: false, dailyTargetMin: 3, dailyTargetMax: 6, selectionsPerSession: 1, nextDueAt: null,
|
||||||
});
|
});
|
||||||
|
expect(new AccountBrowsingSimulationRepository(db).saveSettings(ACCOUNT, {
|
||||||
|
dailyTargetMin: 20, dailyTargetMax: 40, selectionsPerSession: 25,
|
||||||
|
})).toMatchObject({ dailyTargetMin: 20, dailyTargetMax: 40, selectionsPerSession: 25 });
|
||||||
expect(db.prepare(`PRAGMA table_info(account_growth_settings)`).all())
|
expect(db.prepare(`PRAGMA table_info(account_growth_settings)`).all())
|
||||||
.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'daily_limit' })]));
|
.toEqual(expect.arrayContaining([expect.objectContaining({ name: 'daily_limit' })]));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -278,12 +278,12 @@ describe('GrowthPage', () => {
|
|||||||
|
|
||||||
const size = screen.getByLabelText('Selections per session');
|
const size = screen.getByLabelText('Selections per session');
|
||||||
await user.clear(size);
|
await user.clear(size);
|
||||||
await user.type(size, '4');
|
await user.type(size, '25');
|
||||||
await user.click(screen.getByRole('button', { name: 'Save session size' }));
|
await user.click(screen.getByRole('button', { name: 'Save session size' }));
|
||||||
|
|
||||||
await waitFor(() => expect(requests).toEqual(expect.arrayContaining([
|
await waitFor(() => expect(requests).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ method: 'PATCH', body: { minimumGapMs: 5_400_000 } }),
|
expect.objectContaining({ method: 'PATCH', body: { minimumGapMs: 5_400_000 } }),
|
||||||
expect.objectContaining({ method: 'PATCH', body: { selectionsPerSession: 4 } }),
|
expect.objectContaining({ method: 'PATCH', body: { selectionsPerSession: 25 } }),
|
||||||
])));
|
])));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -293,14 +293,14 @@ describe('GrowthPage', () => {
|
|||||||
|
|
||||||
const minimum = await screen.findByLabelText('Daily minimum');
|
const minimum = await screen.findByLabelText('Daily minimum');
|
||||||
await user.clear(minimum);
|
await user.clear(minimum);
|
||||||
await user.type(minimum, '6');
|
await user.type(minimum, '20');
|
||||||
const maximum = screen.getByLabelText('Daily maximum');
|
const maximum = screen.getByLabelText('Daily maximum');
|
||||||
await user.clear(maximum);
|
await user.clear(maximum);
|
||||||
await user.type(maximum, '9');
|
await user.type(maximum, '40');
|
||||||
await user.click(screen.getByRole('button', { name: 'Save daily range' }));
|
await user.click(screen.getByRole('button', { name: 'Save daily range' }));
|
||||||
|
|
||||||
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
|
await waitFor(() => expect(requests).toContainEqual(expect.objectContaining({
|
||||||
method: 'PATCH', body: { dailyTargetMin: 6, dailyTargetMax: 9 },
|
method: 'PATCH', body: { dailyTargetMin: 20, dailyTargetMax: 40 },
|
||||||
})));
|
})));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const OTHER_ACCOUNT = '@t2_other:reddit.com';
|
|||||||
/** Migrations that make up account growth, replayed together in the upgrade test. */
|
/** Migrations that make up account growth, replayed together in the upgrade test. */
|
||||||
// Browsing migrations depend on the growth tables, so a pre-growth fixture
|
// Browsing migrations depend on the growth tables, so a pre-growth fixture
|
||||||
// must rewind past both the growth sequence and the later browsing additions.
|
// must rewind past both the growth sequence and the later browsing additions.
|
||||||
const GROWTH_MIGRATION_COUNT = 6;
|
const GROWTH_MIGRATION_COUNT = 7;
|
||||||
|
|
||||||
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
function thread(id: string, overrides: Partial<RedditPost> = {}): RedditPost {
|
||||||
return {
|
return {
|
||||||
@@ -538,9 +538,9 @@ describe('growth migration', () => {
|
|||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
const version = db.pragma('user_version', { simple: true }) as number;
|
const version = db.pragma('user_version', { simple: true }) as number;
|
||||||
db.exec('ALTER TABLE account_growth_settings DROP COLUMN daily_limit');
|
db.exec('ALTER TABLE account_growth_settings DROP COLUMN daily_limit');
|
||||||
// Browsing simulation is the migration after the growth daily-limit
|
// Rewind the daily-limit migration and every browsing migration that now
|
||||||
// column, so rewind both to replay the real upgrade sequence.
|
// follows it so the upgrade replays against the old growth row shape.
|
||||||
db.pragma(`user_version = ${version - 2}`);
|
db.pragma(`user_version = ${version - 6}`);
|
||||||
insertAccount(db, ACCOUNT);
|
insertAccount(db, ACCOUNT);
|
||||||
// Seed a row with the old shape so the upgrade runs against real data.
|
// Seed a row with the old shape so the upgrade runs against real data.
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
|
|||||||
@@ -262,16 +262,16 @@ describe('growth route', () => {
|
|||||||
it('enables simulation within the conservative hard limits', async () => {
|
it('enables simulation within the conservative hard limits', async () => {
|
||||||
const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}/browsing`, {
|
const { status, json } = await call(app, 'PATCH', `/api/growth/${ENCODED}/browsing`, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
dailyTargetMin: 4,
|
dailyTargetMin: 14,
|
||||||
dailyTargetMax: 8,
|
dailyTargetMax: 28,
|
||||||
minimumGapMs: 3_600_000,
|
minimumGapMs: 3_600_000,
|
||||||
selectionsPerSession: 4,
|
selectionsPerSession: 18,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
const parsed = GrowthAccountResponseSchema.parse(json);
|
const parsed = GrowthAccountResponseSchema.parse(json);
|
||||||
expect(parsed.account.browsing).toMatchObject({
|
expect(parsed.account.browsing).toMatchObject({
|
||||||
settings: { enabled: true, dailyTargetMin: 4, dailyTargetMax: 8, selectionsPerSession: 4 },
|
settings: { enabled: true, dailyTargetMin: 14, dailyTargetMax: 28, selectionsPerSession: 18 },
|
||||||
noVotesSubmitted: true,
|
noVotesSubmitted: true,
|
||||||
});
|
});
|
||||||
expect(pushed).toContainEqual({
|
expect(pushed).toContainEqual({
|
||||||
@@ -280,9 +280,9 @@ describe('growth route', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ dailyTargetMax: 13 },
|
{ dailyTargetMin: 0 },
|
||||||
{ minimumGapMs: 44 * 60_000 },
|
{ dailyTargetMax: 2 },
|
||||||
{ selectionsPerSession: 13 },
|
{ selectionsPerSession: 0 },
|
||||||
{ postWeight: 0, commentWeight: 0 },
|
{ postWeight: 0, commentWeight: 0 },
|
||||||
{ nextDueAt: 123 },
|
{ nextDueAt: 123 },
|
||||||
])('rejects unsafe or non-operator settings %o', async (body) => {
|
])('rejects unsafe or non-operator settings %o', async (body) => {
|
||||||
|
|||||||
Reference in new issue
Block a user