v0.8.1: orphan research-row reaper + GA4 description fix

* feat(cost): persist run_id on generated posts + back-populate for v0.8.1 reaper

The orphan-research-row reaper (upcoming commit) needs a cheap way to
answer "which posts belong to this run_id?" when amortizing a stranded
`llm_research` row back to its siblings. Relying on the generation_log
post_id column is circular: if the pipeline died before amortize_research
ran, we'd trust the same table we're trying to repair.

- `Publisher::build_meta()` now accepts `?string $run_id` and writes
  `_prautoblogger_run_id => $run_id` when non-empty. Null/empty preserves
  existing null-safe legacy behaviour.
- `Publisher::create_post()` forwards the $run_id it already receives
  into build_meta — one-line plumbing, no new parameters at call sites.
- `Activator::backpopulate_run_id_meta_v081()` runs once per site,
  gated by `prautoblogger_migrated_run_id_meta_v081` option. Walks
  `wp_prautoblogger_generation_log`, pairs (post_id, min(run_id)),
  writes `_prautoblogger_run_id` on any post that doesn't already have
  it. Uses `metadata_exists()` so it's a no-op on re-run.
- Activator also schedules the new daily `prautoblogger_reap_orphan_research_rows`
  cron at 03:15 — 15 min after the primary generation cron — so live
  pipelines have time to finish amortizing cleanly before the reaper scans.
- Deactivator unschedules the new hook alongside the existing cron hooks.

No user-visible behaviour change yet. The reaper handler ships in the
next commit; this one just ensures it will have the data + schedule it
needs when it lands.

Part of the v0.8.1 bundle (thread research-cost-reaper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cost): daily reaper for orphan llm_research rows

Implements the orphan-research-row reaper per thread research-cost-reaper
seq 1. When a pipeline dies between post creation and
`Post_Assembler::amortize_research_costs()` (Hostinger exec kill, OOM,
LiteSpeed cut, fatal), the shared research row stays in
`wp_prautoblogger_generation_log` with `post_id = NULL` and sibling
articles' cost popovers understate their true spend.

New class `PRAutoBlogger_Research_Reaper` owns:

- `on_cron()` — WP-Cron entrypoint hooked to the daily
  `prautoblogger_reap_orphan_research_rows` event (already scheduled
  by the Activator in the previous commit at 03:15).
- `reap()` — returns a `{reaped, deleted, skipped}` summary. For each
  orphan `llm_research` row older than 1 hour (grace window for live
  pipelines):
  - `_prautoblogger_run_id` post_meta primary lookup.
  - Falls back to distinct post_ids in gen_log with the same run_id.
  - If ≥1 post found → delegate to the existing idempotent
    `Post_Assembler::amortize_research_costs()`. That method inserts
    the per-article rows and deletes the original orphan in one pass,
    so the reaper is idempotent on re-run.
  - If 0 posts found AND orphan is >7 days old → delete outright as
    sunk cost with nothing to attribute to.
  - Else → skip; try again the next day.
- Never throws. DB errors caught and logged WARNING.

New WP-CLI command `wp prautoblogger reap-research` registered in the
main loader when `WP_CLI` is defined — manual ops trigger that prints
the same summary.

Departure from the CTO's spec: the reap logic lives on
`Research_Reaper` instead of `Post_Assembler`. Placing it on
Post_Assembler pushed that file to 391 lines, over the 300-line cap.
Moving it to the thin class colocates the attribution logic with the
cron/CLI plumbing that triggers it. `amortize_research_costs()` stays
on Post_Assembler — the reaper delegates to it unchanged. Flagged for
CTO review.

Part of the v0.8.1 bundle (thread research-cost-reaper).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cost): ResearchReaperTest — five cases per spec

Covers the five scenarios from convo thread research-cost-reaper seq 1:

- test_reaps_orphan_with_matching_run_id_posts — meta lookup returns
  two post IDs; reaper delegates to amortize_research_costs which
  inserts two rows and deletes the orphan.
- test_skips_orphan_within_grace_period — the SELECT's
  `created_at < now - 1h` clause filters fresh orphans at the DB; the
  reaper's stats stay zero and no side effects fire.
- test_deletes_orphan_with_no_articles_after_7_days — orphan is 8 days
  old, zero siblings via meta or gen_log; reaper deletes outright.
- test_falls_back_to_gen_log_when_post_meta_missing — meta returns
  empty (legacy pre-v0.8.1 post); gen_log has two distinct post_ids;
  amortize still runs.
- test_idempotent_on_repeat_run — second pass on a cleaned state
  returns 0/0/0 with no inserts or deletes.

All DB interaction mocked via $wpdb stub — no real DB round-trips.
Fixtures are driven by property mutations in the test methods so
PHPUnit's "one stub per method" restriction on mocks doesn't bite.

Part of the v0.8.1 bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(v0.8.1): version bump + CHANGELOG + ARCHITECTURE.md for reaper

Bump plugin header and PRAUTOBLOGGER_VERSION 0.8.0 → 0.8.1. Patch
bump — the reaper is internal infrastructure; no user-visible UX
change.

CHANGELOG:
- Fixed: orphan `llm_research` rows now reaped daily.
- Added: WP-CLI `wp prautoblogger reap-research`; `_prautoblogger_run_id`
  post_meta is now written by Publisher on every post.

ARCHITECTURE.md:
- Decision #13 (Run-ID based log linking) cross-refs the new post_meta.
- New Decision #19 documents the reaper's scan/amortize/delete policy,
  the 1-hour grace window, the 7-day stale cutoff, and the one-shot
  activator back-populate migration.

DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump.

Final commit of the v0.8.1 bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cost): widen $wpdb mock so ResearchReaperTest actually runs

BaseTestCase::create_mock_wpdb() only adds methods
[prepare, get_var, get_results, insert, query, get_row, update] — the
reaper and amortize_research_costs also call `delete` and `get_col`,
which the default mock can't stub. CI on PR #100 fired all five new
ResearchReaperTest cases as errors at the first `->method('delete')`
call during setUp.

Fix: build a bespoke $wpdb mock in this file's setUp with the full
superset of methods it needs. Self-contained; no change to
BaseTestCase so existing tests aren't affected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(cost): filter gen_log inserts from event_log inserts in reaper tests

CI on ec1efee showed the two reap-and-amortize tests failing because
`assertCount(2, $this->inserted_rows)` was seeing 4 rows, not 2. Root
cause: the Logger singleton writes INFO lines via `$wpdb->insert` into
the event_log table — the reaper logs once ("Reaped...") and amortize
logs once ("Amortized LLM research cost...") before the two per-post
gen_log inserts, so the capture ended up with 4 total rows.

Add a `gen_log_inserts()` helper that filters the capture down to rows
whose `__table` contains "generation_log" — the assertions on
attribution count should only measure the actual cost inserts, not
incidental log writes. Two tests updated; logic unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): correct misleading GA4 Property ID description

The GA4 client at includes/core/class-ga4-client.php:85 already wraps the
property_id option with "properties/" in the outbound URL path. The admin
description told users to enter "properties/XXXXXXXXX" — which would have
produced a double-prefixed URL (properties/properties/XXXXXXXXX) and a
guaranteed 404 from the Data API. Description now asks for digits only,
matching actual code behavior.

No code logic change — UX copy + CHANGELOG only. Flagged by CEO while
inspecting the Analytics tab; folded into the in-flight v0.8.1 PR per
CTO call rather than cutting a separate patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
peptiderepo
2026-04-21 09:48:34 +07:00
committed by GitHub
parent 188e6603dd
commit 33d3ba4ea1
10 changed files with 593 additions and 7 deletions

View File

@@ -521,7 +521,10 @@ The posts widget uses WordPress-bundled React (`wp.element`) with raw `createEle
The widget fetches posts asynchronously from a dedicated REST endpoint rather than rendering server-side. This keeps initial page load fast and allows the endpoint to be cached independently (5-minute Cache-Control). The permission callback uses a filter (`prautoblogger_rest_posts_public`) so sites can restrict access if needed.
### #13: Run-ID based log linking
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession.
Each pipeline execution generates a UUID (`run_id`) that tags every `prab_generation_log` entry. When a post is published, `link_generation_logs()` uses `UPDATE WHERE run_id = X` to associate entries with the `post_id`. This is more reliable than the previous timestamp-window approach, especially in batch runs where multiple posts are generated in quick succession. **v0.8.1+**: `run_id` is also persisted on each post as `_prautoblogger_run_id` post_meta, giving the orphan-research reaper a direct path from run to siblings without walking the gen_log table (see #19).
### #19: Daily orphan-research-row reaper (v0.8.1)
When a pipeline dies between post creation and the final `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill, OOM, LiteSpeed cut, fatal), the shared `llm_research` cost row stays in `prab_generation_log` with `post_id = NULL` and sibling articles understate their true cost in the breakdown popover. A new daily WP-Cron event `prautoblogger_reap_orphan_research_rows` (hooked to `PRAutoBlogger_Research_Reaper::on_cron`, 03:15 server time — 15 min after the primary generation cron) scans for orphan rows older than a 1-hour grace window, looks up sibling posts by `_prautoblogger_run_id` post_meta (fallback: distinct `post_id`s in gen_log for the same run_id), and delegates to the existing idempotent `amortize_research_costs()` on match. Orphans that still have no siblings after 7 days are deleted outright as sunk cost. A one-time activator migration (`prautoblogger_migrated_run_id_meta_v081`) back-populates `_prautoblogger_run_id` on existing posts from gen_log so the meta-primary path works on upgrade. Manual ops trigger: `wp prautoblogger reap-research`.
### #14: Reddit RSS replaces PullPush.io (and earlier Reddit OAuth)
Reddit rejected our OAuth API application (April 2026). We initially switched to PullPush.io, but its index was frequently stale or unavailable. Reddit's RSS/Atom feeds (`/r/{sub}/hot.rss`) proved the most reliable option — they work from datacenter IPs where .json gets 403, require no auth, and have no apparent rate limit. The .json endpoints are kept as a fallback for posts and as the sole source for comment data. Each collected item's metadata includes a `data_source` field (`reddit_rss` or `reddit_json`) for auditability.

View File

@@ -5,6 +5,32 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.8.1] — 2026-04-21
### Fixed
- **Orphaned LLM research costs now reaped daily.** When a pipeline died
between post creation and the final cost-amortization step (e.g.
execution-time kill on shared hosting), the `llm_research` row stayed
unlinked and the affected articles' cost popovers understated true spend.
A new daily cron (`prautoblogger_reap_orphan_research_rows`, 03:15
server time) retroactively amortizes orphan rows against posts from
the same run. Includes a one-time activator migration to back-populate
`_prautoblogger_run_id` post_meta for existing posts.
- **GA4 Property ID admin description corrected.** The field description
previously instructed users to enter `properties/XXXXXXXXX`, but the
GA4 client already prepends the `properties/` URL segment internally —
a double-prefixed value would 404. Description now asks for the digits
only, matching actual code behavior.
### Added
- New WP-CLI command `wp prautoblogger reap-research` for manual reaping
and ops debugging.
- `Publisher::build_meta()` now writes `_prautoblogger_run_id` on every
generated post, so the reaper has a direct meta path from run_id to
sibling posts without walking `wp_prautoblogger_generation_log`.
## [0.8.0] — 2026-04-21
### Changed

View File

@@ -154,7 +154,7 @@ class PRAutoBlogger_Settings_Fields_Extended {
'label' => __( 'GA4 Property ID', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_analytics',
'description' => __( 'Format: properties/XXXXXXXXX. Leave blank to skip GA4.', 'prautoblogger' ),
'description' => __( 'Your GA4 Property ID — digits only, e.g. 123456789 (the code adds the "properties/" prefix internally). Find it in GA4 → Admin → Property details. Leave blank to skip GA4.', 'prautoblogger' ),
],
[
'id' => 'prautoblogger_ga4_credentials_json',

View File

@@ -28,6 +28,7 @@ class PRAutoBlogger_Activator {
self::create_tables();
self::set_default_options();
self::schedule_cron();
self::backpopulate_run_id_meta_v081();
// Store the DB version so we can run migrations on future updates.
update_option( 'prautoblogger_db_version', PRAUTOBLOGGER_DB_VERSION, false );
@@ -225,5 +226,62 @@ class PRAutoBlogger_Activator {
if ( ! wp_next_scheduled( 'prautoblogger_collect_metrics' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'prautoblogger_six_hours', 'prautoblogger_collect_metrics' );
}
// v0.8.1: daily reaper for orphan `llm_research` rows. Pinned to
// 03:15 so it runs 15 min after the primary generation cron, giving
// any live pipeline time to finish and call amortize_research_costs
// cleanly before the reaper scans.
if ( ! wp_next_scheduled( 'prautoblogger_reap_orphan_research_rows' ) ) {
$tomorrow = strtotime( 'tomorrow 03:15' );
if ( false !== $tomorrow ) {
wp_schedule_event( $tomorrow, 'daily', 'prautoblogger_reap_orphan_research_rows' );
}
}
}
/**
* One-shot v0.8.1 migration — back-populate `_prautoblogger_run_id`
* post_meta for existing posts from their gen-log rows.
*
* Lets the orphan-research-row reaper (v0.8.1+) attribute historic
* orphan rows to sibling posts via post_meta lookup, without having
* to walk the generation_log table. Gated by
* `prautoblogger_migrated_run_id_meta_v081` so it runs exactly once
* per site.
*
* Side effects: adds post_meta rows for existing PRAutoBlogger posts
* that don't already have `_prautoblogger_run_id`.
*
* @return void
*/
private static function backpopulate_run_id_meta_v081(): void {
if ( get_option( 'prautoblogger_migrated_run_id_meta_v081' ) ) {
return;
}
global $wpdb;
$gen_log = $wpdb->prefix . 'prautoblogger_generation_log';
// For each (post_id, run_id) pair present in the gen-log, write
// the run_id meta if it's not already set. Uses the first run_id
// seen per post — in practice each post is linked to exactly one run.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$pairs = $wpdb->get_results(
"SELECT post_id, MIN(run_id) AS run_id FROM {$gen_log}
WHERE post_id IS NOT NULL AND run_id IS NOT NULL AND run_id != ''
GROUP BY post_id"
);
if ( is_array( $pairs ) ) {
foreach ( $pairs as $row ) {
$post_id = (int) ( $row->post_id ?? 0 );
$run_id = (string) ( $row->run_id ?? '' );
if ( $post_id > 0 && '' !== $run_id && ! metadata_exists( 'post', $post_id, '_prautoblogger_run_id' ) ) {
update_post_meta( $post_id, '_prautoblogger_run_id', $run_id );
}
}
}
update_option( 'prautoblogger_migrated_run_id_meta_v081', '1' );
}
}

View File

@@ -36,6 +36,7 @@ class PRAutoBlogger_Deactivator {
$hooks = [
'prautoblogger_daily_generation',
'prautoblogger_collect_metrics',
'prautoblogger_reap_orphan_research_rows',
];
foreach ( $hooks as $hook ) {

View File

@@ -116,6 +116,24 @@ class PRAutoBlogger {
// Single-idea generation from the Ideas browser page.
add_action( 'prautoblogger_generate_from_idea', [ 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ] );
// v0.8.1: daily reaper for orphan `llm_research` rows (when a
// pipeline dies before amortize_research_costs runs).
add_action( 'prautoblogger_reap_orphan_research_rows', [ 'PRAutoBlogger_Research_Reaper', 'on_cron' ] );
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
// WP-CLI is present; no-op in normal HTTP requests.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
\WP_CLI::add_command( 'prautoblogger reap-research', static function (): void {
$stats = PRAutoBlogger_Research_Reaper::reap();
\WP_CLI::success( sprintf(
'Reaped %d, deleted %d (stale), skipped %d.',
(int) $stats['reaped'],
(int) $stats['deleted'],
(int) $stats['skipped']
) );
} );
}
}
/** Register AJAX handlers for admin actions. */

View File

@@ -87,7 +87,7 @@ class PRAutoBlogger_Publisher {
'post_status' => $post_status,
'post_type' => 'post',
'post_author' => PRAutoBlogger_Post_Assembler::get_default_author_id(),
'meta_input' => $this->build_meta( $idea, $review ),
'meta_input' => $this->build_meta( $idea, $review, $run_id ),
];
/** @see class-prautoblogger.php — listeners registered in main loader. */
@@ -120,15 +120,22 @@ class PRAutoBlogger_Publisher {
/**
* Build post_meta array for generation metadata.
*
* The `_prautoblogger_run_id` key (added v0.8.1) lets the orphan-research
* reaper attribute an orphan `llm_research` row back to its sibling
* articles without re-walking the gen_log table. See
* core/class-research-reaper.php.
*
* @param PRAutoBlogger_Article_Idea $idea
* @param PRAutoBlogger_Editorial_Review $review
* @param string|null $run_id Pipeline run UUID, or null in legacy paths.
* @return array<string, mixed>
*/
private function build_meta(
PRAutoBlogger_Article_Idea $idea,
PRAutoBlogger_Editorial_Review $review
PRAutoBlogger_Editorial_Review $review,
?string $run_id = null
): array {
return [
$meta = [
'_prautoblogger_generated' => '1',
'_prautoblogger_analysis_id' => $idea->get_analysis_id(),
'_prautoblogger_source_ids' => wp_json_encode( $idea->get_source_ids() ),
@@ -143,5 +150,9 @@ class PRAutoBlogger_Publisher {
'_prautoblogger_article_type' => $idea->get_article_type(),
'_prautoblogger_target_keywords' => wp_json_encode( $idea->get_target_keywords() ),
];
if ( null !== $run_id && '' !== $run_id ) {
$meta['_prautoblogger_run_id'] = $run_id;
}
return $meta;
}
}

View File

@@ -0,0 +1,183 @@
<?php
declare(strict_types=1);
/**
* Daily cron that retroactively amortizes orphan `llm_research` rows.
*
* When a pipeline run dies between post creation and the final
* `Post_Assembler::amortize_research_costs()` call (Hostinger exec kill,
* OOM, LiteSpeed cut, fatal), the shared research-cost row stays in
* `wp_prautoblogger_generation_log` with `post_id = NULL` and the
* articles from that run understate their true cost in the popover.
*
* This reaper scans for such orphan rows once a day, finds their sibling
* posts via the `_prautoblogger_run_id` post_meta (primary) or the
* gen_log's own post-linked rows (fallback for legacy posts that
* predate the meta), and delegates to the existing idempotent
* `amortize_research_costs()` method. Orphans that still have no
* siblings after 7 days are deleted outright as sunk cost.
*
* Spec-wise the CTO asked for the reap method to live on Post_Assembler;
* hosting it here instead keeps Post_Assembler under the 300-line cap
* (it grew past 300 with the reap method attached) and colocates the
* attribution logic next to the cron/CLI plumbing that triggers it.
*
* Triggered by:
* - WP-Cron: `prautoblogger_reap_orphan_research_rows` (daily, 03:15).
* - WP-CLI: `wp prautoblogger reap-research` (manual ops trigger).
* Dependencies: PRAutoBlogger_Post_Assembler (amortize_research_costs),
* PRAutoBlogger_Logger, WordPress $wpdb + get_posts.
*
* @see core/class-post-assembler.php — amortize_research_costs() does the work.
* @see class-activator.php — Schedules the daily cron.
* @see class-deactivator.php — Unschedules on deactivate.
* @see ARCHITECTURE.md — Cost tracking data flow.
*/
class PRAutoBlogger_Research_Reaper {
/** Grace window — live pipelines have this long to finish amortizing before reaping. */
private const GRACE_WINDOW_SECONDS = 3600; // 1 hour.
/** Stale window — orphans with no sibling posts are deleted outright after this long. */
private const STALE_WINDOW_SECONDS = 7 * 86400; // 7 days.
/**
* Cron action handler — entrypoint for the daily WP-Cron event.
*
* Pure delegate. Never throws.
*
* @return void
*/
public static function on_cron(): void {
self::reap();
}
/**
* Scan and reap orphan `llm_research` rows. Returns a summary that the
* WP-CLI command prints and the cron path ignores.
*
* Side effects: SELECT / INSERT / DELETE on generation_log; post_meta
* reads; one INFO log line per reaped or stale-deleted
* orphan; one WARNING on DB error.
*
* @return array{reaped: int, deleted: int, skipped: int}
*/
public static function reap(): array {
$stats = [ 'reaped' => 0, 'deleted' => 0, 'skipped' => 0 ];
try {
global $wpdb;
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
$grace = gmdate( 'Y-m-d H:i:s', time() - self::GRACE_WINDOW_SECONDS );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$orphans = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, run_id, created_at FROM {$log_table}
WHERE stage = 'llm_research' AND post_id IS NULL AND created_at < %s",
$grace
)
);
if ( ! is_array( $orphans ) ) {
return $stats;
}
$stale_cutoff = time() - self::STALE_WINDOW_SECONDS;
foreach ( $orphans as $orphan ) {
self::handle_orphan( $orphan, $stale_cutoff, $log_table, $stats );
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->warning(
'Reaper encountered DB error: ' . $e->getMessage(),
'cost-tracker'
);
}
return $stats;
}
/**
* Route one orphan row: amortize if siblings exist, delete if stale,
* otherwise skip and try again next run.
*
* @param object $orphan DB row with id, run_id, created_at.
* @param int $stale_cutoff Unix timestamp — orphans older than this delete.
* @param string $log_table Fully-qualified gen_log table name.
* @param array $stats Counters, mutated by reference.
* @return void
*/
private static function handle_orphan( object $orphan, int $stale_cutoff, string $log_table, array &$stats ): void {
global $wpdb;
$run_id = (string) ( $orphan->run_id ?? '' );
$created_ts = strtotime( (string) ( $orphan->created_at ?? '' ) );
$is_stale = false !== $created_ts && $created_ts < $stale_cutoff;
if ( '' === $run_id ) {
if ( $is_stale ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
$stats['deleted']++;
} else {
$stats['skipped']++;
}
return;
}
$post_ids = self::find_posts_for_run_id( $run_id );
if ( count( $post_ids ) >= 1 ) {
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Reaped research cost across %d articles for run %s.', count( $post_ids ), $run_id ),
'cost-tracker'
);
$stats['reaped']++;
return;
}
if ( $is_stale ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$wpdb->delete( $log_table, [ 'id' => (int) $orphan->id ] );
PRAutoBlogger_Logger::instance()->info(
sprintf( 'Deleted 7-day-stale orphan research row for run %s — no articles to attribute.', $run_id ),
'cost-tracker'
);
$stats['deleted']++;
return;
}
$stats['skipped']++;
}
/**
* Return distinct post IDs for a given run_id. Prefers post_meta;
* falls back to gen_log rows for legacy posts.
*
* @param string $run_id Pipeline run UUID.
* @return int[] Distinct post IDs.
*/
private static function find_posts_for_run_id( string $run_id ): array {
$meta_ids = get_posts( [
'post_type' => 'any',
'post_status' => 'any',
'meta_key' => '_prautoblogger_run_id',
'meta_value' => $run_id,
'fields' => 'ids',
'posts_per_page' => -1,
'suppress_filters' => true,
] );
if ( is_array( $meta_ids ) && ! empty( $meta_ids ) ) {
return array_map( 'intval', $meta_ids );
}
global $wpdb;
$log_table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$ids = $wpdb->get_col(
$wpdb->prepare(
"SELECT DISTINCT post_id FROM {$log_table} WHERE run_id = %s AND post_id IS NOT NULL",
$run_id
)
);
return is_array( $ids ) ? array_map( 'intval', $ids ) : [];
}
}

View File

@@ -8,7 +8,7 @@ declare(strict_types=1);
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.8.0
* Version: 0.8.1
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.8.0' );
define( 'PRAUTOBLOGGER_VERSION', '0.8.1' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

View File

@@ -0,0 +1,286 @@
<?php
/**
* Tests for PRAutoBlogger_Research_Reaper.
*
* Exercises the five scenarios spec'd in convo thread
* research-cost-reaper/01-cto-handoff.md: meta-based reap, grace-period
* skip, 7-day stale delete, gen_log fallback when meta is missing, and
* idempotency on repeat runs.
*
* All DB work is mocked via a $wpdb stub — no real database round-trips.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class ResearchReaperTest extends BaseTestCase {
/**
* @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb.
*/
private $wpdb;
/**
* Captured INSERT rows (from amortize_research_costs delegating to $wpdb->insert).
*
* @var array<int, array<string, mixed>>
*/
private array $inserted_rows = [];
/**
* Captured DELETE calls: [$table, $where-array].
*
* @var array<int, array{0: string, 1: array<string, mixed>}>
*/
private array $deleted_rows = [];
/**
* Fixture: the rows get_results should hand back for the orphan scan.
*
* @var array<int, object>
*/
private array $orphan_fixtures = [];
/**
* Fixture: post IDs the reaper's fallback gen_log query should return.
*
* @var int[]
*/
private array $gen_log_post_ids = [];
protected function setUp(): void {
parent::setUp();
$this->inserted_rows = [];
$this->deleted_rows = [];
$this->orphan_fixtures = [];
$this->gen_log_post_ids = [];
// Bespoke $wpdb mock — BaseTestCase::create_mock_wpdb() doesn't
// stub `delete` or `get_col`, both of which the reaper + amortize
// call into. Wide-method mock keeps the test scope self-contained.
$this->wpdb = $this->getMockBuilder( \stdClass::class )
->addMethods( [ 'prepare', 'get_var', 'get_results', 'get_row', 'get_col', 'insert', 'delete', 'update', 'query' ] )
->getMock();
$this->wpdb->prefix = 'wp_';
$this->wpdb->insert_id = 1;
$this->wpdb->last_error = '';
$this->wpdb->prab_cost_logs = 'wp_prab_cost_logs';
$this->wpdb->method( 'insert' )->willReturnCallback(
function ( $table, $row ) {
$this->inserted_rows[] = array_merge( [ '__table' => $table ], $row );
return 1;
}
);
$this->wpdb->method( 'delete' )->willReturnCallback(
function ( $table, $where ) {
$this->deleted_rows[] = [ (string) $table, (array) $where ];
return 1;
}
);
// prepare() returns its args joined so we can pattern-match in get_* stubs.
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( $sql, ...$args ) {
return $sql . '|' . implode( '|', array_map( 'strval', $args ) );
}
);
// get_results routes by SQL fragment between the reaper's orphan scan
// and the occasional Logger insert probes.
$this->wpdb->method( 'get_results' )->willReturnCallback(
function ( $sql ) {
if ( false !== stripos( (string) $sql, "post_id IS NULL AND created_at <" ) ) {
return $this->orphan_fixtures;
}
return [];
}
);
// get_row serves amortize_research_costs's research-row lookup.
$this->wpdb->method( 'get_row' )->willReturnCallback(
function ( $sql ) {
foreach ( $this->orphan_fixtures as $o ) {
if ( false !== stripos( (string) $sql, (string) $o->run_id ) ) {
return (object) [
'id' => $o->id,
'estimated_cost' => $o->estimated_cost ?? 0.50,
'provider' => $o->provider ?? 'openrouter',
'model' => $o->model ?? 'gemini-flash',
'prompt_tokens' => $o->prompt_tokens ?? 1000,
'completion_tokens' => $o->completion_tokens ?? 500,
];
}
}
return null;
}
);
// get_col is the reaper's fallback "posts by run_id" and amortize's
// "distinct post_ids" query — both read from the same fixture.
$this->wpdb->method( 'get_col' )->willReturnCallback(
fn() => array_map( 'strval', $this->gen_log_post_ids )
);
$GLOBALS['wpdb'] = $this->wpdb;
$this->stub_get_option( [
'prautoblogger_log_level' => 'info',
] );
// Reaper uses get_posts to look up sibling posts via post_meta.
Functions\when( 'get_posts' )->justReturn( [] );
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* Return only the `generation_log` inserts from the capture — filters out
* the `event_log` rows that Logger::instance()->info() writes as a side
* effect of the reaper + amortize's own progress logging.
*
* @return array<int, array<string, mixed>>
*/
private function gen_log_inserts(): array {
return array_values( array_filter(
$this->inserted_rows,
static fn( $row ) => false !== strpos( (string) ( $row['__table'] ?? '' ), 'generation_log' )
) );
}
/**
* Helper: seed the orphan list returned by the reaper's primary SELECT.
*
* @param array<int, array{id: int, run_id: string, created_at: string, estimated_cost?: float, provider?: string, model?: string, prompt_tokens?: int, completion_tokens?: int}> $orphans
*/
private function seed_orphans( array $orphans ): void {
$this->orphan_fixtures = array_map(
static fn( $r ) => (object) $r,
$orphans
);
}
/**
* Helper: set the post_ids the gen_log fallback query returns.
*
* @param int[] $post_ids
*/
private function seed_gen_log_posts_for_run( array $post_ids ): void {
$this->gen_log_post_ids = $post_ids;
}
/**
* 1. Reap succeeds via post_meta primary lookup.
*
* Orphan row is >1h old, `_prautoblogger_run_id` meta returns two
* post IDs, amortize inserts two attributed rows and deletes the
* original orphan.
*/
public function test_reaps_orphan_with_matching_run_id_posts(): void {
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
$this->seed_orphans( [ [ 'id' => 99, 'run_id' => 'run-abc', 'created_at' => $old_ts, 'estimated_cost' => 0.40 ] ] );
// In the v0.8.1 world both sources agree. Meta is the reaper's gate;
// gen_log also has rows so `amortize_research_costs()` can do its
// own internal post-count query.
Functions\when( 'get_posts' )->alias(
static fn( $args ) => ( '_prautoblogger_run_id' === ( $args['meta_key'] ?? '' ) && 'run-abc' === ( $args['meta_value'] ?? '' ) )
? [ 101, 102 ]
: []
);
$this->seed_gen_log_posts_for_run( [ 101, 102 ] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 1, $stats['reaped'] );
$this->assertSame( 0, $stats['deleted'] );
$this->assertSame( 0, $stats['skipped'] );
$this->assertCount(
2,
$this->gen_log_inserts(),
'One amortized row per post should be inserted into the generation_log.'
);
// Original orphan deleted by amortize_research_costs.
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 99 ) );
}
/**
* 2. Orphan within the 1-hour grace window is left alone — a live
* pipeline may still be in the middle of its own amortize step.
*/
public function test_skips_orphan_within_grace_period(): void {
// The primary SELECT filters on `created_at < (now - 1h)`, so a
// fresh orphan is excluded from the result set at the DB boundary.
// We simulate that by returning an empty orphan list.
$this->seed_orphans( [] );
Functions\when( 'get_posts' )->justReturn( [ 101, 102 ] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 0, $stats['reaped'] );
$this->assertSame( 0, $stats['deleted'] );
$this->assertSame( 0, $stats['skipped'] );
$this->assertEmpty( $this->inserted_rows );
$this->assertEmpty( $this->deleted_rows );
}
/**
* 3. Orphan older than 7 days with zero matching posts is deleted
* outright — research cost is sunk with nothing to attribute to.
*/
public function test_deletes_orphan_with_no_articles_after_7_days(): void {
$stale_ts = gmdate( 'Y-m-d H:i:s', time() - ( 8 * DAY_IN_SECONDS ) );
$this->seed_orphans( [ [ 'id' => 77, 'run_id' => 'run-dead', 'created_at' => $stale_ts ] ] );
// No posts via meta, no posts via gen_log.
Functions\when( 'get_posts' )->justReturn( [] );
$this->seed_gen_log_posts_for_run( [] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 0, $stats['reaped'] );
$this->assertSame( 1, $stats['deleted'] );
$this->assertNotEmpty( array_filter( $this->deleted_rows, static fn( $d ) => ( $d[1]['id'] ?? null ) === 77 ) );
}
/**
* 4. When `_prautoblogger_run_id` post_meta is absent (legacy posts
* predating v0.8.1), the reaper falls back to querying gen_log for
* distinct post_ids carrying the same run_id.
*/
public function test_falls_back_to_gen_log_when_post_meta_missing(): void {
$old_ts = gmdate( 'Y-m-d H:i:s', time() - ( 2 * HOUR_IN_SECONDS ) );
$this->seed_orphans( [ [ 'id' => 55, 'run_id' => 'run-legacy', 'created_at' => $old_ts, 'estimated_cost' => 0.30 ] ] );
// Meta lookup returns nothing.
Functions\when( 'get_posts' )->justReturn( [] );
// But gen_log has two posts with this run_id.
$this->seed_gen_log_posts_for_run( [ 201, 202 ] );
$stats = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 1, $stats['reaped'] );
$this->assertCount( 2, $this->gen_log_inserts() );
}
/**
* 5. A second reap run is a no-op — the first run deleted the orphan
* and inserted amortized per-article rows, so the scan returns empty.
*/
public function test_idempotent_on_repeat_run(): void {
// Simulate "already reaped" state: scan returns no orphans.
$this->seed_orphans( [] );
$stats_1 = \PRAutoBlogger_Research_Reaper::reap();
$stats_2 = \PRAutoBlogger_Research_Reaper::reap();
$this->assertSame( 0, $stats_1['reaped'] );
$this->assertSame( 0, $stats_2['reaped'] );
$this->assertEmpty( $this->inserted_rows );
$this->assertEmpty( $this->deleted_rows );
}
}