This repository was archived by the owner on Jun 19, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(dry-run): implement /internal/scheduler/dry-run-replay (was a v0.1 stub) #14
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -354,10 +354,27 @@ app.post('/internal/menu/dashboard', async (c) => { | |||||
| recent.push({ ...h, id: String(m.member) }); | ||||||
| } | ||||||
|
|
||||||
| // Dry-run results for the draft rules (written by /internal/scheduler/dry-run-replay). | ||||||
| const dryRunLines: string[] = []; | ||||||
| for (const r of draft?.rules ?? []) { | ||||||
| const raw = await redis.get(`${subredditName}:dryrun:${r.id}`); | ||||||
| if (!raw) continue; | ||||||
| const d = JSON.parse(raw) as DryRunResult; | ||||||
| if (d.status === 'ok') { | ||||||
| dryRunLines.push( | ||||||
| ` ${r.id}: would match ${d.matched.length}/${d.sampledPosts} recent post(s)` + | ||||||
| (d.matched.length ? ` → ${[...new Set(d.matched.flatMap((m) => m.would))].join(', ')}` : ''), | ||||||
| ); | ||||||
| } else { | ||||||
| dryRunLines.push(` ${r.id}: ${d.note ?? 'dry-run unavailable'}`); | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| const summary = [ | ||||||
| `Active rules: ${active?.rules.length ?? 0}`, | ||||||
| `Draft rules: ${draft?.rules.length ?? 0}`, | ||||||
| `Recent actions: ${recent.length}`, | ||||||
| ...(dryRunLines.length ? ['', 'Dry-run preview (draft rules):', ...dryRunLines] : []), | ||||||
| '', | ||||||
| 'Recent actions:', | ||||||
| ...recent.slice(0, 10).map((r) => ` ${r.action} (${r.outcome}) — ${(r.ruleSourceNL ?? '').slice(0, 60)}…`), | ||||||
|
|
@@ -598,10 +615,89 @@ app.post('/internal/scheduler/audit-retention', async (c) => { | |||||
| return c.json<TaskResponse>({ status: 'ok' }); | ||||||
| }); | ||||||
|
|
||||||
| // Dry-run preview (hard lock #3 — forced before Activate). When a rule is | ||||||
| // compiled into the draft, this job runs immediately, replays the *last few | ||||||
| // posts* through the draft rule (no actions taken — pure evaluation), and writes | ||||||
| // a `${sub}:dryrun:${ruleId}` summary the Dashboard renders. v0.1 samples posts | ||||||
| // only (no `getNewComments` in the SDK); a comment-only rule gets a "shadow-mode | ||||||
| // it to see real comments" note. | ||||||
| const DRY_RUN_SAMPLE = 10; // recent posts to replay (×~3 Reddit API calls each via the fact bag) | ||||||
| const DRY_RUN_TTL_SECONDS = 7 * 24 * 60 * 60; | ||||||
|
|
||||||
| interface DryRunResult { | ||||||
| ruleId: string; | ||||||
| ruleSourceNL: string; | ||||||
| ranAt: number; | ||||||
| status: 'ok' | 'unavailable'; | ||||||
| note?: string; | ||||||
| sampledPosts: number; | ||||||
| matched: Array<{ thingId: string; thingType: 'post'; authorName: string; would: string[] }>; | ||||||
| } | ||||||
|
|
||||||
| app.post('/internal/scheduler/dry-run-replay', async (c) => { | ||||||
| await c.req.json<TaskRequest<{ ruleId: string; subredditName: string }>>(); | ||||||
| // v0.1: simplified — pull last 100 posts and log what would have happened. | ||||||
| // Full implementation reads rules:draft, builds factBags, evaluates, writes simulated audits. | ||||||
| const body = await c.req.json<TaskRequest<{ ruleId: string; subredditName: string }>>(); | ||||||
| const ruleId = body.data?.ruleId; | ||||||
| const sub = getCurrentSubredditName(); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||
| if (!ruleId) return c.json<TaskResponse>({ status: 'ok' }); | ||||||
|
|
||||||
| const result: DryRunResult = { | ||||||
| ruleId, | ||||||
| ruleSourceNL: '', | ||||||
| ranAt: Date.now(), | ||||||
| status: 'ok', | ||||||
| sampledPosts: 0, | ||||||
| matched: [], | ||||||
| }; | ||||||
| try { | ||||||
| const draftJson = await redis.get(`${sub}:rules:draft`); | ||||||
| const rule = draftJson ? RuleBundle.parse(JSON.parse(draftJson)).rules.find((r) => r.id === ruleId) : undefined; | ||||||
| if (!rule) { | ||||||
| result.status = 'unavailable'; | ||||||
| result.note = `Rule ${ruleId} is no longer in the draft.`; | ||||||
| } else { | ||||||
| result.ruleSourceNL = rule.sourceNL; | ||||||
| const postTrigger = rule.on.includes('onPostSubmit') | ||||||
| ? 'onPostSubmit' | ||||||
| : rule.on.includes('onPostReport') | ||||||
| ? 'onPostReport' | ||||||
| : null; | ||||||
| if (!postTrigger) { | ||||||
| result.status = 'unavailable'; | ||||||
| result.note = | ||||||
| 'This rule listens to comment events. v0.1 dry-run replays recent posts only — activate it (shadow mode is ON by default) to see how it behaves on real comments.'; | ||||||
| } else { | ||||||
| const posts = await reddit.getNewPosts({ subredditName: sub, limit: DRY_RUN_SAMPLE }).all(); | ||||||
| for (const p of posts) { | ||||||
| result.sampledPosts++; | ||||||
| const facts = await buildPostFactBag( | ||||||
| { | ||||||
| id: p.id, | ||||||
| title: p.title, | ||||||
| body: p.body ?? '', | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
| url: p.url, | ||||||
| authorId: p.authorId ?? 't2_unknown', | ||||||
| authorName: p.authorName, | ||||||
| }, | ||||||
| p.numberOfReports ?? 0, | ||||||
| ); | ||||||
| if (selectMatchingRules([rule], postTrigger, facts).length > 0) { | ||||||
| result.matched.push({ | ||||||
| thingId: p.id, | ||||||
| thingType: 'post', | ||||||
| authorName: p.authorName, | ||||||
| would: rule.then.map((a) => a.action), | ||||||
| }); | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } catch (err) { | ||||||
| result.status = 'unavailable'; | ||||||
| result.note = `Dry-run replay couldn't complete (${String(err).slice(0, 120)}). Activate in shadow mode to observe live behaviour instead.`; | ||||||
| } | ||||||
|
|
||||||
| await redis.set(`${sub}:dryrun:${ruleId}`, JSON.stringify(result)); | ||||||
| await redis.expire(`${sub}:dryrun:${ruleId}`, DRY_RUN_TTL_SECONDS); | ||||||
| return c.json<TaskResponse>({ status: 'ok' }); | ||||||
| }); | ||||||
|
|
||||||
|
|
||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,10 @@ | ||
| // src/server/routes-scheduler.test.ts | ||
| // Functional call-tests for the cron-scheduled jobs: audit retention, dry-run | ||
| // replay (stub), shadow→live promotion, and the actions/hour circuit breaker. | ||
| // replay, shadow→live promotion, and the actions/hour circuit breaker. | ||
|
|
||
| import { describe, it, expect } from 'vitest'; | ||
| import app from './index'; | ||
| import { fakeRedis, fakeReddit, fakeSettings } from '../../test/setup'; | ||
| import { fakeRedis, fakeReddit, fakeSettings, fakeListing } from '../../test/setup'; | ||
| import { Rule, RuleBundle, type RuleType } from '../shared/rule-schema'; | ||
|
|
||
| const call = (path: string, body: unknown = {}) => | ||
|
|
@@ -60,12 +60,84 @@ describe('POST /internal/scheduler/audit-retention', () => { | |
| }); | ||
|
|
||
| describe('POST /internal/scheduler/dry-run-replay', () => { | ||
| it('acknowledges (v0.1 stub)', async () => { | ||
| expect( | ||
| await ( | ||
| await call('/internal/scheduler/dry-run-replay', { data: { ruleId: 'r_x', subredditName: 'testsub' } }) | ||
| ).json(), | ||
| ).toEqual({ status: 'ok' }); | ||
| const post = (id: string, over: Record<string, unknown> = {}) => ({ | ||
| id, | ||
| title: 'a title', | ||
| body: 'a body', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| url: 'https://example.com', | ||
| authorId: 't2_a', | ||
| authorName: 'alice', | ||
| numberOfReports: 0, | ||
| ...over, | ||
| }); | ||
|
|
||
| it('writes an "unavailable" summary when the rule is no longer in the draft', async () => { | ||
| await call('/internal/scheduler/dry-run-replay', { data: { ruleId: 'r_gone', subredditName: 'testsub' } }); | ||
| const d = JSON.parse((await fakeRedis.get('testsub:dryrun:r_gone'))!); | ||
| expect(d.status).toBe('unavailable'); | ||
| expect(d.note).toMatch(/no longer in the draft/i); | ||
| }); | ||
|
|
||
| it('replays recent posts through a draft post-rule and records which would match', async () => { | ||
| // rule: matches authors with <50 karma (the mocked author defaults to 0 karma → matches) | ||
| await fakeRedis.set( | ||
| 'testsub:rules:draft', | ||
| JSON.stringify( | ||
| bundleWith({ | ||
| id: 'r_lowk', | ||
| when: { fact: 'author.totalKarma', op: 'lt', value: 50 }, | ||
| then: [{ action: 'modqueue', params: { note: 'x' } }], | ||
| }), | ||
| ), | ||
| ); | ||
| fakeReddit.getNewPosts.mockReturnValue(fakeListing([post('t3_a'), post('t3_b')])); | ||
|
|
||
| await call('/internal/scheduler/dry-run-replay', { data: { ruleId: 'r_lowk', subredditName: 'testsub' } }); | ||
| const d = JSON.parse((await fakeRedis.get('testsub:dryrun:r_lowk'))!); | ||
| expect(d.status).toBe('ok'); | ||
| expect(d.sampledPosts).toBe(2); | ||
| expect(d.matched.map((m: { thingId: string }) => m.thingId)).toEqual(['t3_a', 't3_b']); | ||
| expect(d.matched[0].would).toEqual(['modqueue']); | ||
| }); | ||
|
|
||
| it('records zero matches when no recent post satisfies the rule', async () => { | ||
| await fakeRedis.set( | ||
| 'testsub:rules:draft', | ||
| JSON.stringify(bundleWith({ id: 'r_hik', when: { fact: 'author.totalKarma', op: 'gt', value: 1_000_000 } })), | ||
| ); | ||
| fakeReddit.getNewPosts.mockReturnValue(fakeListing([post('t3_x')])); | ||
| await call('/internal/scheduler/dry-run-replay', { data: { ruleId: 'r_hik', subredditName: 'testsub' } }); | ||
| const d = JSON.parse((await fakeRedis.get('testsub:dryrun:r_hik'))!); | ||
| expect(d.status).toBe('ok'); | ||
| expect(d.sampledPosts).toBe(1); | ||
| expect(d.matched).toEqual([]); | ||
| }); | ||
|
|
||
| it('marks a comment-only rule "unavailable" with a shadow-mode hint', async () => { | ||
| await fakeRedis.set('testsub:rules:draft', JSON.stringify(bundleWith({ id: 'r_cmt', on: ['onCommentSubmit'] }))); | ||
| await call('/internal/scheduler/dry-run-replay', { data: { ruleId: 'r_cmt', subredditName: 'testsub' } }); | ||
| const d = JSON.parse((await fakeRedis.get('testsub:dryrun:r_cmt'))!); | ||
| expect(d.status).toBe('unavailable'); | ||
| expect(d.note).toMatch(/comment events.*shadow mode/is); | ||
| }); | ||
|
|
||
| it('degrades gracefully (status unavailable, never throws) when the Reddit API fails', async () => { | ||
| await fakeRedis.set('testsub:rules:draft', JSON.stringify(bundleWith({ id: 'r_err' }))); | ||
| fakeReddit.getNewPosts.mockImplementation(() => { | ||
| throw new Error('reddit 503'); | ||
| }); | ||
| const res = await call('/internal/scheduler/dry-run-replay', { | ||
| data: { ruleId: 'r_err', subredditName: 'testsub' }, | ||
| }); | ||
| expect(await res.json()).toEqual({ status: 'ok' }); | ||
| const d = JSON.parse((await fakeRedis.get('testsub:dryrun:r_err'))!); | ||
| expect(d.status).toBe('unavailable'); | ||
| expect(d.note).toContain('503'); | ||
| }); | ||
|
|
||
| it('is a no-op when no ruleId is supplied', async () => { | ||
| expect(await (await call('/internal/scheduler/dry-run-replay', { data: {} })).json()).toEqual({ status: 'ok' }); | ||
| expect(fakeReddit.getNewPosts).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Parsing JSON from Redis without a try-catch block can lead to a full application crash if the stored data is malformed or corrupted. Since this is in the dashboard route, it would prevent moderators from accessing the tool's management interface.