feat: M4 in-admin manual generation on chained-cron checkpoints + IA/perf pass (v0.21.0) (#166)

Final Phase 2 admin milestone. Board "New Article" runs through the M3 chained-cron checkpoint state machine (one stage/idea per tick, queue persisted before generate, lock released on every exit path) so generation survives Hostinger background-kill; retires the SSH-only do_action workaround (WP-CLI routes through the same entry). IA/perf: settings-backed board/ideas pagination + db 1.4.0 indexes. Folds guard P2s (--self-test + window doc).

QA: APPROVE-WITH-P2-NOTES @ f2a00d4 + delta APPROVE @ 0c8071f. Deferred (tracked): class-admin-page.php 353-line split + pagination Settings-UI fields.

Agent-Session: phase2-admin-m4-20260614
This commit is contained in:
peptiderepo
2026-06-15 00:48:43 +07:00
committed by GitHub
parent 326a1af900
commit d770e8a86a
30 changed files with 1457 additions and 251 deletions

View File

@@ -126,3 +126,7 @@ jobs:
# by defined() at the reference site. Prevents the v0.19.0 incident
# class (constant deleted from bootstrap, test fallback masks the gap).
run: php scripts/check-constants.php
- name: Self-test check-constants guard (v0.21.0)
# Verifies the guard script itself catches a synthetic missing constant.
# Exit 2 = self-test failure (scanner is broken). Exit 0 = scanner works.
run: php scripts/check-constants.php --self-test

View File

@@ -1084,3 +1084,65 @@ PRAutoBlogger and Peptide News both call OpenRouter and may share a single API k
**Important:** If your OpenRouter account has a global spending limit, set each plugin's budget to less than half the total. PRAutoBlogger will hard-stop when its budget is exhausted, but Peptide News currently has no budget enforcement — a spike in news fetches could consume shared quota.
**Future improvement:** A shared `wp_options` key (e.g., `ecosystem_monthly_llm_budget`) that both plugins read, with each plugin reserving its allocation on startup. This requires coordination at the ecosystem level and is tracked as a medium-term goal.
## §25 Chained-Cron Checkpoint Generation Path (v0.21.0, M4)
### Problem
Hostinger shared hosting kills PHP background processes after ~120 s. The former
`on_manual_generation()` path in `Executor` ran the complete orchestrate +
generate pipeline in a single PHP process — meaning a host kill mid-run left the
generation lock held, the run row stuck in `running`, and partially-generated
articles orphaned with no status broadcast.
### Solution: Two-Tick Checkpoint Machine
`PRAutoBlogger_Generation_Checkpoint_Runner` (v0.21.0) splits the pipeline into
independent cron ticks, each of which completes well within Hostinger's limit:
| Tick | WP-Cron action | What happens |
|------|---------------|-------------|
| 1 | `prautoblogger_gen_orchestrate` | Collect sources → analyze → score ideas. Serialize ideas to `prautoblogger_article_queue` option. Schedule Tick 2. |
| 2..N | `prautoblogger_gen_tick` | Pop ONE idea from queue. Call `Article_Worker::generate()`. Persist updated queue. Reschedule next Tick, or finalize. |
If the host kills any tick:
- The idea queue is persisted (option survived the crash).
- The next CRON run picks up naturally (no orphan).
- `Run_Reaper` reclaims truly stuck runs via its daily sweep.
### Entry Points
| Caller | Path |
|--------|------|
| Board "New Article" button | AJAX `prautoblogger_generate_now` → `Generation_Status_Poller::on_ajax_generate_now()` → schedules `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
| WP-CLI | `wp prautoblogger generate` → `WP_CLI_Commands::generate_command()` → `kick_off()` |
| Old cron hook (retained) | `prautoblogger_manual_generation` → `Executor::on_manual_generation()` → `kick_off()` |
### SSH Workaround Retirement
The former `wp eval 'do_action("prautoblogger_manual_generation")'` workaround
fired `on_manual_generation()` synchronously in the CLI PHP process, bypassing:
- Nonce/capability checks (running as CLI user, not WP user)
- Cron-based loopback spawning
- Per-process time limits
This path is retired. `Executor::on_manual_generation()` now delegates immediately
to `Generation_Checkpoint_Runner::on_orchestrate_tick()` (the Tick 1 handler),
which acquires the lock and runs orchestration within its own process. The
`wp prautoblogger generate` WP-CLI command provides the supported CLI path.
### Key State
| Store | Key | Contents |
|-------|-----|---------|
| DB option | `prautoblogger_article_queue` | `{run_id, ideas[], results{}}` — persisted across ticks |
| DB option | `prautoblogger_checkpoint_run_id` | Current run UUID (for finalize/cleanup paths) |
| Transient | `prautoblogger_generation_status` | Status broadcast (polling by board/dossier JS) |
| DB | `prautoblogger_runs` | Per-run ledger with status, cost ceiling |
### Budget / Halt Contract
`Cost_Tracker::is_budget_exceeded()` is checked at the START of Tick 1 and each
generate tick. A halted run (`Run_State::get_status() = 'halted'`) causes the
generate tick to abort without calling `Article_Worker`, clean up the queue, and
release the lock — same guarantee as `Pipeline_Runner`'s queued-article path.

View File

@@ -10,6 +10,61 @@ and this project uses [Semantic Versioning](https://semver.org/).
### Internal
- CI constants guard (`scripts/check-constants.php`): new required CI step (`Check PRAUTOBLOGGER_* constants`) fails the build when any `PRAUTOBLOGGER_*` constant referenced in shipped plugin PHP is neither defined in `prautoblogger.php` nor `defined()`-guarded at the reference site. Prevents a recurrence of the v0.19.0 admin-500 regression class.
## [0.21.0] - 2026-06-14
### Added
- **Board “New Article” button (Phase 2 admin M4)**. Manual generation is now
accessible from the kanban board (not just the Settings page). The button fires
the existing `prautoblogger_generate_now` AJAX action; the board status area
shows generation progress via the existing transient poller. Requires
`manage_options` capability.
- **Chained-cron checkpoint machine** (`PRAutoBlogger_Generation_Checkpoint_Runner`).
Replaces the monolithic `on_manual_generation()` synchronous path with a
two-tick checkpoint machine so Hostinger’s PHP-process kill cannot abort a
mid-pipeline run:
- Tick 1 (`prautoblogger_gen_orchestrate`): collect → analyze → score ideas,
persist to `prautoblogger_article_queue` option, schedule Tick 2.
- Tick 2…N (`prautoblogger_gen_tick`): pop ONE idea, generate one article,
reschedule or finalize. Persists consumed queue BEFORE generating to prevent
orphan-recovery duplication on restart.
- Budget / halt: `Cost_Tracker::is_budget_exceeded()` checked per tick;
a halted run aborts all remaining ticks without calling `Article_Worker`.
- **WP-CLI `wp prautoblogger generate` command**. Routes through the same
chained-cron checkpoints as the board button. Replaces the retired SSH-only
`wp eval 'do_action("prautoblogger_manual_generation")'` workaround.
- **Settings-backed board pagination** (perf pass). All hardcoded LIMIT / `posts_per_page`
values in board queries replaced with two new settings:
- `prautoblogger_board_column_limit` (default 20, constant `PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT`)
- `prautoblogger_ideas_per_page` (default 30, constant `PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE`)
- **DB indexes** (via `PRAUTOBLOGGER_DB_VERSION` → `1.4.0`):
- `generation_log`: `KEY run_id_post_id`, `KEY created_status` (board query + failed-cards).
- `analysis_results`: `KEY analyzed_type` (ideas-browser pagination + type filter).
- `runs`: `KEY started_at` (articles-list pagination `ORDER BY started_at DESC LIMIT n OFFSET m`).
- **Query helper split**: `PRAutoBlogger_Ideas_Browser_Query` (new `class-ideas-browser-query.php`)
carries static query helpers extracted from `class-ideas-browser.php` to enforce the
300-line rule (CONVENTIONS §1).
- **Guard P2s** (`scripts/check-constants.php`):
- Added `--self-test` mode: synthesizes a missing constant and asserts the scanner catches it.
- Added inline comment documenting `CONSTANTS_GUARD_WINDOW = 4`.
- Wired `--self-test` as a CI step after the main constants check.
- **PHPUnit test** `tests/unit/Core/GenerationCheckpointRunnerTest.php`: tick-by-tick
state-machine proof (kick_off schedules only, empty queue finalizes, halted run aborts,
multi-idea queue pops one and reschedules).
- **Pipeline_Status** now exports `write_initial()` and `write_error()` for the
checkpoint runner to set the status transient before the first cron tick fires.
### Changed
- `Executor::on_manual_generation()` now delegates to
`Generation_Checkpoint_Runner::on_orchestrate_tick()` instead of running the full
pipeline synchronously. The method body is 1 line; the synchronous path is retired.
- `Pipeline_Runner::orchestrate_only()` added as a public wrapper exposing the private
`orchestrate()` method for use by `Generation_Checkpoint_Runner`.
### Removed
- **SSH-only workaround retired**: `wp eval 'do_action("prautoblogger_manual_generation")'` no
longer fires the pipeline synchronously in one PHP process. Use `wp prautoblogger generate`
instead (routes through the chained-cron checkpoint machine). See ARCHITECTURE.md §25.
## [0.20.1] - 2026-06-12
### Fixed

View File

@@ -0,0 +1,84 @@
# Information Architecture — PRAutoBlogger
Auto-generated index of all admin surfaces, data flows, and integrations.
Maintained per DoD v1.1.0 (same-PR update rule).
---
## Admin Pages
| Slug | Class | Template | Purpose |
|------|-------|----------|---------|
| `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) |
| `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). |
| `prautoblogger-articles` | `PRAutoBlogger_Articles_Page` | `articles-page.php` | Paginated list of all articles/runs |
| `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3) |
| `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation |
| `prautoblogger-activity` | `PRAutoBlogger_Activity_Page` | `activity-page.php` | Cost + generation activity log |
---
## Generation Entry Points (v0.21.0)
```
Board "New Article" button
└─ AJAX prautoblogger_generate_now
└─ Generation_Status_Poller::on_ajax_generate_now()
└─ schedules prautoblogger_manual_generation cron
└─ Executor::on_manual_generation()
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
└─ schedules prautoblogger_gen_tick
└─ Generation_Checkpoint_Runner::on_generate_tick() ← Tick 2..N
WP-CLI: wp prautoblogger generate
└─ WP_CLI_Commands::generate_command()
└─ Generation_Checkpoint_Runner::kick_off()
└─ schedules prautoblogger_gen_orchestrate
└─ Generation_Checkpoint_Runner::on_orchestrate_tick() ← Tick 1
Daily auto-generation cron
└─ prautoblogger_daily_generation
└─ Executor::on_cron_run()
└─ Pipeline_Runner::run() (legacy synchronous path — runs in a single PHP process)
```
---
## Database Tables
| Table | DB Version | Purpose |
|-------|-----------|---------|
| `prautoblogger_sources` | 1.0.0 | Collected Reddit/RSS source content |
| `prautoblogger_analysis_results` | 1.0.0 | Scored article ideas |
| `prautoblogger_generation_log` | 1.0.0 | Per-LLM-call log (cost, tokens, status) |
| `prautoblogger_runs` | 1.0.0 | Per-run ledger (status, ceiling_usd) |
| `prautoblogger_run_stages` | 1.2.0 | Per-stage state for M3 re-run |
| `prautoblogger_stage_inputs` | 1.3.0 | Immutable input versions for stage edits (M3) |
| `prautoblogger_run_decisions` | 1.2.0 | Editorial decisions per run |
DB version history: 1.0→1.1 (schema normalisation), 1.1→1.2 (M2 dossier), 1.2→1.3 (M3 stage inputs), 1.3→1.4 (M4 indexes).
---
## Key Options
| Option | Type | Default | Purpose |
|--------|------|---------|---------|
| `prautoblogger_board_column_limit` | int | 20 | Max cards per board column |
| `prautoblogger_ideas_per_page` | int | 30 | Ideas browser page size |
| `prautoblogger_article_queue` | array | — | Checkpoint queue (run_id + ideas + results) |
| `prautoblogger_checkpoint_run_id` | string | — | Current run UUID for Tick 2..N finalize path |
| `prautoblogger_db_version` | string | — | Installed schema version (triggers dbDelta on mismatch) |
---
## WP-CLI Commands
| Command | Since | Description |
|---------|-------|-------------|
| `wp prautoblogger generate` | 0.21.0 | Queue article generation via chained-cron checkpoints |
| `wp prautoblogger opik:eval` | 0.12.0 | Trigger Opik eval run |
---
*Last updated: v0.21.0 (2026-06-14)*

View File

@@ -0,0 +1,53 @@
/**
* PRAutoBlogger Board -- "New Article" generate button handler (v0.21.0, M4).
*
* Loaded on the board page alongside board.js. Depends on the prabBoard config
* object and jQuery, both already present when board.js loads.
*
* @see assets/js/board.js -- Board poller (loads first).
* @see includes/admin/class-board-page.php -- Enqueues + localizes prabBoard.
*/
( function ( $ ) {
'use strict';
var cfg = window.prabBoard || {};
/**
* Handle click on the "New Article" button. Fires the existing generate_now
* AJAX action and triggers board backoff reset so the new card appears fast.
*/
function onBoardGenerateNow() {
var $btn = $( '#prab-board-generate-now' );
if ( ! $btn.length || $btn.prop( 'disabled' ) ) {
return;
}
$btn.prop( 'disabled', true ).text( cfg.strings && cfg.strings.generatingBtn || 'Generating\u2026' );
var $status = $( '#prab-board-gen-status' );
$status.text( '' ).hide();
$.ajax( {
url: cfg.ajaxUrl,
method: 'POST',
data: { action: 'prautoblogger_generate_now', nonce: cfg.generateNonce },
success: function ( response ) {
if ( response && response.success ) {
$status.text( cfg.strings && cfg.strings.genStarted || 'Generation started.' ).show();
// Notify board poller to reset backoff (run is now active).
$( document ).trigger( 'prab:generation-started' );
} else {
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
}
},
error: function () {
$status.text( cfg.strings && cfg.strings.genError || 'Generation failed.' ).show();
$btn.prop( 'disabled', false ).text( cfg.strings && cfg.strings.newArticle || 'New Article' );
}
} );
}
$( function () {
$( document ).on( 'click', '#prab-board-generate-now', onBoardGenerateNow );
} );
}( jQuery ) );

View File

@@ -164,6 +164,11 @@
renderColumn( 'in_review', snapshot.in_review || [] );
renderColumn( 'published', snapshot.published || [] );
renderColumn( 'failed', snapshot.failed || [] );
// Re-enable board Generate button once no active runs remain.
if ( ! snapshot.has_active_runs ) {
$( '#prab-board-generate-now' ).prop( 'disabled', false )
.text( ( cfg.strings && cfg.strings.newArticle ) || 'New Article' );
}
}
/**
@@ -225,22 +230,7 @@
} );
}
/** Initialise the board poller on DOM ready. */
$( function () {
$board = $( '#prab-board' );
if ( ! $board.length ) {
return;
}
// Inject the error banner after the heading.
$errorBanner = $( '<div class="prab-board-error" role="alert">' + escHtml( cfg.strings.pollError || '' ) + '</div>' );
$board.before( $errorBanner );
// Check if any generating card is present in the initial server render.
var hasActive = ( $board.find( '[data-column="generating"] .prab-board-card' ).length > 0 );
backoffFactor = hasActive ? 1 : 1; // Always start at base on first load.
schedulePoll( nextDelay( hasActive ) );
} );
}( jQuery ) );

View File

@@ -324,7 +324,7 @@ class PRAutoBlogger_Admin_Page {
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
}
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days' );
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days', 'prautoblogger_board_column_limit', 'prautoblogger_ideas_per_page' );
if ( in_array( $option_name, $numeric, true ) ) {
return is_numeric( $value ) ? $value : 0;
}

View File

@@ -94,7 +94,7 @@ class PRAutoBlogger_Board_Data_Provider {
array(
'post_type' => 'post',
'post_status' => 'draft',
'posts_per_page' => 20,
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
'meta_query' => array(
array(
'key' => '_prautoblogger_generated',
@@ -144,7 +144,7 @@ class PRAutoBlogger_Board_Data_Provider {
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 20,
'posts_per_page' => max( 5, (int) get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ),
'date_query' => array(
array(
'after' => $date_after,

View File

@@ -76,8 +76,9 @@ class PRAutoBlogger_Board_Gen_Log_Query {
AND post_id IS NULL
GROUP BY run_id
ORDER BY started_at DESC
LIMIT 10",
$since
LIMIT %d",
$since,
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
),
ARRAY_A
);
@@ -148,8 +149,9 @@ class PRAutoBlogger_Board_Gen_Log_Query {
AND post_id IS NULL
GROUP BY run_id
ORDER BY started_at DESC
LIMIT 20",
$since
LIMIT %d",
$since,
(int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) )
),
ARRAY_A
);

View File

@@ -4,27 +4,27 @@ declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Registers and renders the kanban board — the primary landing screen for PRAutoBlogger.
* Registers and renders the kanban board -- the primary landing screen for PRAutoBlogger.
*
* Board columns: Generating | In Review | Published | Failed.
* Cards live-update via AJAX polling at a settings-backed interval (default 5s),
* backing off to 2× when no active generating run is detected.
* backing off to 2x when no active generating run is detected.
*
* Card click-throughs (M1):
* - Generating → Activity Log filtered to run context
* - In Review → Review Queue
* - Published → Post edit screen
* - Failed → Activity Log
* - Generating -> Activity Log filtered to run context
* - In Review -> Review Queue
* - Published -> Post edit screen
* - Failed -> Activity Log
*
* M2 will rewire all click-throughs to the Article Dossier page.
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
* Dependencies: PRAutoBlogger_Board_Data_Provider, wp_localize_script.
*
* @see admin/class-board-data-provider.php — Supplies card data.
* @see templates/admin/board-page.php — HTML template.
* @see assets/js/board.js — Polling + DOM updates.
* @see ARCHITECTURE.md — §Board (kanban dashboard).
* @see admin/class-board-data-provider.php -- Supplies card data.
* @see templates/admin/board-page.php -- HTML template.
* @see assets/js/board.js -- Polling + DOM updates.
* @see ARCHITECTURE.md -- Section: Board (kanban dashboard).
*/
class PRAutoBlogger_Board_Page {
@@ -48,7 +48,7 @@ class PRAutoBlogger_Board_Page {
* $admin_page_hooks['prautoblogger-settings'] before add_submenu_page()
* runs here, so get_plugin_page_hookname() resolves to the correct
* 'prautoblogger_page_prautoblogger-board' suffix rather than the fallback
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md §Board.
* 'admin_page_prautoblogger-board'. See ARCHITECTURE.md Section: Board.
*
* @return void
*/
@@ -128,6 +128,13 @@ class PRAutoBlogger_Board_Page {
PRAUTOBLOGGER_VERSION,
true
);
wp_enqueue_script(
'prautoblogger-board-generate',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board-generate.js',
array( 'jquery', 'prautoblogger-board' ),
PRAUTOBLOGGER_VERSION,
true
);
$poll_interval = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
$published_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
@@ -138,6 +145,7 @@ class PRAutoBlogger_Board_Page {
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ),
'pollInterval' => $poll_interval * 1000,
'publishedWindowDays' => $published_days,
'action' => self::AJAX_ACTION,
@@ -151,8 +159,12 @@ class PRAutoBlogger_Board_Page {
'view' => __( 'View', 'prautoblogger' ),
'edit' => __( 'Edit', 'prautoblogger' ),
'viewLog' => __( 'View Log', 'prautoblogger' ),
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
'pollError' => __( 'Board update failed -- retrying.', 'prautoblogger' ),
'humanModified' => __( 'Human-modified', 'prautoblogger' ),
'newArticle' => __( 'New Article', 'prautoblogger' ),
'generatingBtn' => __( 'Generating...', 'prautoblogger' ),
'genStarted' => __( 'Generation started. Board will update automatically.', 'prautoblogger' ),
'genError' => __( 'Generation failed to start. Check the Activity Log.', 'prautoblogger' ),
),
)
);
@@ -163,7 +175,7 @@ class PRAutoBlogger_Board_Page {
*
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
*
* Side effects: DB reads only — no writes.
* Side effects: DB reads only -- no writes.
*
* @return void
*/

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Query helpers for PRAutoBlogger_Ideas_Browser.
*
* Extracted to keep class-ideas-browser.php under 300 lines (CONVENTIONS §1).
* All methods are static; class is never instantiated.
*/
class PRAutoBlogger_Ideas_Browser_Query {
/** Fall-through if setting is absent. */
public const PER_PAGE_DEFAULT = 30;
/** Transient key prefix for per-idea generation status. */
public const STATUS_PREFIX = 'prab_idea_gen_';
/** How long to keep per-idea status (seconds). */
public const STATUS_TTL = 600;
/** Query analysis results with optional filtering and pagination. */
public static function query_ideas( int $paged, string $type ): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
$where = array();
$params = array();
if ( '' !== $type ) {
$where[] = 'analysis_type = %s';
$params[] = $type;
}
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
$per_page = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', self::PER_PAGE_DEFAULT ) );
$offset = ( $paged - 1 ) * $per_page;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', $per_page, $offset );
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return array(
'rows' => $rows ?? array(),
'total' => $total,
);
}
/** Get counts per analysis_type for the filter badges. */
public static function get_type_counts(): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
ARRAY_A
);
$counts = array();
foreach ( $rows ?? array() as $row ) {
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
}
return $counts;
}
/**
* Load an analysis result row and map it to Article_Idea constructor data.
*
* @param int $idea_id Analysis result row ID.
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
*/
public static function load_idea_data( int $idea_id ): ?array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
ARRAY_A
);
if ( ! $row ) {
return null;
}
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
return array(
'topic' => $row['topic'],
'article_type' => $row['analysis_type'],
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
'summary' => $row['summary'] ?? '',
'score' => (float) $row['relevance_score'],
'analysis_id' => (int) $row['id'],
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
'key_points' => $meta['key_points'] ?? array(),
'target_keywords' => $meta['target_keywords'] ?? array(),
);
}
/** Find the most recent post created by a specific run_id. */
public static function find_post_by_run_id( string $run_id ): ?int {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$post_id = $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
$run_id
)
);
return null !== $post_id ? (int) $post_id : null;
}
/** Update the per-idea status transient with a stage message. */
public static function update_idea_stage( int $idea_id, string $stage ): void {
$key = self::STATUS_PREFIX . $idea_id;
$current = get_transient( $key );
if ( is_array( $current ) ) {
$current['stage'] = $stage;
set_transient( $key, $current, self::STATUS_TTL );
}
}
}

View File

@@ -8,20 +8,14 @@ declare(strict_types=1);
*
* Triggered by: PRAutoBlogger::register_admin_hooks() on `admin_menu`.
* Dependencies: WordPress $wpdb, Article_Worker, Cost_Tracker, Generation_Lock.
* Query helpers: class-ideas-browser-query.php (split for 300-line rule, CONVENTIONS §1).
*
* @see core/class-article-worker.php — Generates articles from ideas.
* @see templates/admin/ideas-browser.php — Renders the HTML.
* @see class-ideas-browser-query.php — Static query helpers.
*/
class PRAutoBlogger_Ideas_Browser {
private const PER_PAGE = 30;
/** Transient prefix for per-idea generation status. */
private const STATUS_PREFIX = 'prab_idea_gen_';
/** How long to keep per-idea status (seconds). */
private const STATUS_TTL = 600;
/** Register the Ideas submenu page under PRAutoBlogger. */
public function on_register_menu(): void {
add_submenu_page(
@@ -43,11 +37,12 @@ class PRAutoBlogger_Ideas_Browser {
$paged = isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1;
$type_filter = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : '';
$result = $this->query_ideas( $paged, $type_filter );
$rows = $result['rows'];
$total = $result['total'];
$total_pages = (int) ceil( $total / self::PER_PAGE );
$type_counts = $this->get_type_counts();
$result = PRAutoBlogger_Ideas_Browser_Query::query_ideas( $paged, $type_filter );
$rows = $result['rows'];
$total = $result['total'];
$per_page_view = max( 5, (int) get_option( 'prautoblogger_ideas_per_page', PRAutoBlogger_Ideas_Browser_Query::PER_PAGE_DEFAULT ) );
$total_pages = (int) ceil( $total / $per_page_view );
$type_counts = PRAutoBlogger_Ideas_Browser_Query::get_type_counts();
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/ideas-browser.php';
}
@@ -74,23 +69,26 @@ class PRAutoBlogger_Ideas_Browser {
return;
}
$status_prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
$status_ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
// Build Article_Idea from the analysis row.
$idea_data = self::load_idea_data( $idea_id );
$idea_data = PRAutoBlogger_Ideas_Browser_Query::load_idea_data( $idea_id );
if ( null === $idea_data ) {
wp_send_json_error( array( 'message' => 'Idea not found.' ) );
return;
}
// Store idea for the cron handler and set initial "running" status.
set_transient( self::STATUS_PREFIX . 'data_' . $idea_id, $idea_data, self::STATUS_TTL );
set_transient( $status_prefix . 'data_' . $idea_id, $idea_data, $status_ttl );
set_transient(
self::STATUS_PREFIX . $idea_id,
$status_prefix . $idea_id,
array(
'status' => 'running',
'stage' => __( 'Starting generation…', 'prautoblogger' ),
'started' => time(),
),
self::STATUS_TTL
$status_ttl
);
// Schedule background cron — passes idea_id as argument.
@@ -124,7 +122,7 @@ class PRAutoBlogger_Ideas_Browser {
}
$idea_id = absint( $_POST['idea_id'] ?? 0 );
$status = get_transient( self::STATUS_PREFIX . $idea_id );
$status = get_transient( PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX . $idea_id );
if ( ! is_array( $status ) ) {
wp_send_json_success( array( 'status' => 'idle' ) );
return;
@@ -149,16 +147,19 @@ class PRAutoBlogger_Ideas_Browser {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
$key = self::STATUS_PREFIX . $idea_id;
$idea_data = get_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
$prefix = PRAutoBlogger_Ideas_Browser_Query::STATUS_PREFIX;
$ttl = PRAutoBlogger_Ideas_Browser_Query::STATUS_TTL;
$key = $prefix . $idea_id;
$idea_data = get_transient( $prefix . 'data_' . $idea_id );
if ( ! is_array( $idea_data ) ) {
set_transient(
$key,
array(
'status' => 'error',
'status' => 'error',
'message' => 'Idea data expired.',
),
self::STATUS_TTL
$ttl
);
return;
}
@@ -167,10 +168,10 @@ class PRAutoBlogger_Ideas_Browser {
set_transient(
$key,
array(
'status' => 'error',
'status' => 'error',
'message' => 'Another generation is running.',
),
self::STATUS_TTL
$ttl
);
return;
}
@@ -180,7 +181,7 @@ class PRAutoBlogger_Ideas_Browser {
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( wp_generate_uuid4() );
self::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
PRAutoBlogger_Ideas_Browser_Query::update_idea_stage( $idea_id, __( 'Generating article draft…', 'prautoblogger' ) );
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$result = $worker->generate( $idea );
@@ -188,7 +189,7 @@ class PRAutoBlogger_Ideas_Browser {
PRAutoBlogger_Post_Assembler::amortize_research_costs( $cost_tracker->get_run_id() );
// Find the generated post ID for the "View" link.
$post_id = self::find_post_by_run_id( $cost_tracker->get_run_id() );
$post_id = PRAutoBlogger_Ideas_Browser_Query::find_post_by_run_id( $cost_tracker->get_run_id() );
set_transient(
$key,
@@ -199,7 +200,7 @@ class PRAutoBlogger_Ideas_Browser {
'cost' => $result['cost'],
'post_id' => $post_id,
),
self::STATUS_TTL
$ttl
);
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
@@ -209,129 +210,14 @@ class PRAutoBlogger_Ideas_Browser {
set_transient(
$key,
array(
'status' => 'error',
'status' => 'error',
'message' => $e->getMessage(),
),
self::STATUS_TTL
$ttl
);
}
PRAutoBlogger_Generation_Lock::release();
delete_transient( self::STATUS_PREFIX . 'data_' . $idea_id );
}
// ── Private helpers ─────────────────────────────────────────────────
/** Update the per-idea status transient with a stage message. */
private static function update_idea_stage( int $idea_id, string $stage ): void {
$key = self::STATUS_PREFIX . $idea_id;
$current = get_transient( $key );
if ( is_array( $current ) ) {
$current['stage'] = $stage;
set_transient( $key, $current, self::STATUS_TTL );
}
}
/** Find the most recent post created by a specific run_id. */
private static function find_post_by_run_id( string $run_id ): ?int {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$post_id = $wpdb->get_var(
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT DISTINCT post_id FROM {$table} WHERE run_id = %s AND post_id IS NOT NULL LIMIT 1",
$run_id
)
);
return null !== $post_id ? (int) $post_id : null;
}
/**
* Load an analysis result row and map it to Article_Idea constructor data.
*
* @param int $idea_id Analysis result row ID.
* @return array<string, mixed>|null Article_Idea-compatible array, or null.
*/
private static function load_idea_data( int $idea_id ): ?array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$row = $wpdb->get_row( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $idea_id ),
ARRAY_A
);
if ( ! $row ) {
return null;
}
$meta = json_decode( $row['metadata_json'] ?? '{}', true ) ?? array();
return array(
'topic' => $row['topic'],
'article_type' => $row['analysis_type'],
'suggested_title' => $meta['suggested_title'] ?? $row['topic'],
'summary' => $row['summary'] ?? '',
'score' => (float) $row['relevance_score'],
'analysis_id' => (int) $row['id'],
'source_ids' => json_decode( $row['source_ids_json'] ?? '[]', true ) ?? array(),
'key_points' => $meta['key_points'] ?? array(),
'target_keywords' => $meta['target_keywords'] ?? array(),
);
}
/** Query analysis results with optional filtering and pagination. */
private function query_ideas( int $paged, string $type ): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
$where = array();
$params = array();
if ( '' !== $type ) {
$where[] = 'analysis_type = %s';
$params[] = $type;
}
$where_sql = empty( $where ) ? '' : 'WHERE ' . implode( ' AND ', $where );
$offset = ( $paged - 1 ) * self::PER_PAGE;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? "SELECT COUNT(*) FROM {$table} {$where_sql}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->prepare( "SELECT COUNT(*) FROM {$table} {$where_sql}", ...$params ) // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
);
$order_sql = 'ORDER BY analyzed_at DESC, relevance_score DESC';
$limit_sql = sprintf( 'LIMIT %d OFFSET %d', self::PER_PAGE, $offset );
$full_sql = "SELECT * FROM {$table} {$where_sql} {$order_sql} {$limit_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = empty( $params ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
? $wpdb->get_results( $full_sql, ARRAY_A ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
: $wpdb->get_results( $wpdb->prepare( $full_sql, ...$params ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
return array(
'rows' => $rows ?? array(),
'total' => $total,
);
}
/** Get counts per analysis_type for the filter badges. */
private function get_type_counts(): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_analysis_results';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT analysis_type, COUNT(*) AS cnt FROM {$table} GROUP BY analysis_type ORDER BY cnt DESC",
ARRAY_A
);
$counts = array();
foreach ( $rows ?? array() as $row ) {
$counts[ $row['analysis_type'] ] = (int) $row['cnt'];
}
return $counts;
delete_transient( $prefix . 'data_' . $idea_id );
}
}

View File

@@ -19,6 +19,14 @@ class PRAutoBlogger_WP_CLI_Commands {
}
// Command: wp prautoblogger opik:eval
\WP_CLI::add_command(
'prautoblogger generate',
array( self::class, 'generate_command' ),
array(
'shortdesc' => 'Queue a new article generation run on chained-cron checkpoints',
)
);
\WP_CLI::add_command(
'prautoblogger opik:eval',
array( self::class, 'opik_eval_command' ),
@@ -69,4 +77,27 @@ class PRAutoBlogger_WP_CLI_Commands {
exit( $result['items_run'] > 0 ? 0 : 1 );
}
/**
* Queue a new article generation run on chained-cron checkpoints.
*
* Routes through PRAutoBlogger_Generation_Checkpoint_Runner::kick_off()
* (same path as the board "New Article" button). Does NOT execute
* synchronously -- check the board or Activity Log for progress.
* This retires the `do_action("prautoblogger_manual_generation")` workaround.
*
* ## OPTIONS
*
* ## EXAMPLES
*
* wp prautoblogger generate
*
* @param array $args Positional args (unused).
* @param array $assoc_args Associative args (unused).
* @return void
*/
public static function generate_command( array $args, array $assoc_args ): void {
PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
\WP_CLI::success( 'Generation queued on chained-cron checkpoints. Check the board or Activity Log for progress.' );
}
}

View File

@@ -80,7 +80,10 @@ class PRAutoBlogger_Activator {
'prautoblogger_table_borders' => '1',
'prautoblogger_schedule_time' => '03:00',
'prautoblogger_log_level' => 'info',
'prautoblogger_image_nsfw_retry' => '1',
'prautoblogger_image_nsfw_retry' => '1',
// M4 (v0.21.0): board column card limit + ideas page size -- settings-backed.
'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
'prautoblogger_ideas_per_page' => PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE,
);
foreach ( $defaults as $key => $value ) {

View File

@@ -85,68 +85,21 @@ class PRAutoBlogger_Executor {
}
/**
* Cron handler: runs pipeline orchestration + first article.
* Cron handler: kick off the chained-cron orchestration tick (v0.21.0, M4).
*
* If the pipeline queues additional articles (2..N), they will fire as
* chained cron events — each in its own PHP process. The lock is released
* by the pipeline when the last article completes, not here.
* Delegates to Generation_Checkpoint_Runner::on_orchestrate_tick() which
* runs Tick 1 (collect -> analyze -> score), then schedules Tick 2..N for
* article generation. This replaces the former monolithic pipeline run and
* retires the SSH-only `do_action("prautoblogger_manual_generation")` workaround
* (see ARCHITECTURE.md ss25).
*
* Lock acquisition and status transient writes are fully owned by the
* checkpoint runner; this method is a thin routing wrapper.
*
* @return void
*/
public function on_manual_generation(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
return;
}
try {
$this->poller()->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
$results = $runner->run();
// If no articles were queued (target=1 or 0 ideas), finalize here.
// When articles ARE queued, the pipeline handles status + lock release.
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'complete',
'generated' => $results['generated'],
'published' => $results['published'],
'rejected' => $results['rejected'],
'cost' => $results['cost'],
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
PRAutoBlogger_Generation_Lock::release();
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Manual generation %s: %s', get_class( $e ), $e->getMessage() ),
'pipeline'
);
set_transient(
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array(
'status' => 'error',
'message' => $e->getMessage(),
),
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
);
$this->mark_current_run_failed();
PRAutoBlogger_Generation_Lock::release();
}
PRAutoBlogger_Generation_Checkpoint_Runner::on_orchestrate_tick();
}
/** Cron handler: process next queued article. Chained by Pipeline_Runner. */

View File

@@ -115,7 +115,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer {
updated_at DATETIME NOT NULL,
finished_at DATETIME DEFAULT NULL,
PRIMARY KEY (run_id),
KEY status (status)
KEY status (status),
KEY started_at (started_at)
) {$charset_collate};";
// Per-run per-stage state rows. The idempotency key is

View File

@@ -146,6 +146,10 @@ class PRAutoBlogger {
add_action( PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, array( $rerun_executor, 'on_replay_job' ), 10, 5 );
add_action( PRAutoBlogger_Rerun_Executor::REBUILD_ACTION, array( $rerun_executor, 'on_rebuild_job' ), 10, 4 );
// v0.21.0 (M4): chained-cron checkpoint ticks for new article generation.
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_orchestrate_tick' ) );
add_action( PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION, array( 'PRAutoBlogger_Generation_Checkpoint_Runner', 'on_generate_tick' ) );
add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) );
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );

View File

@@ -71,7 +71,8 @@ class PRAutoBlogger_Schema_Installer {
metadata_json LONGTEXT DEFAULT NULL,
PRIMARY KEY (id),
KEY type_relevance (analysis_type, relevance_score),
KEY analyzed_at (analyzed_at)
KEY analyzed_at (analyzed_at),
KEY analyzed_type (analyzed_at, analysis_type)
) {$charset_collate};";
// Generation log — every API call with cost tracking. `run_id`
@@ -100,7 +101,9 @@ class PRAutoBlogger_Schema_Installer {
KEY post_id (post_id),
KEY run_id (run_id),
KEY created_at (created_at),
KEY stage (stage)
KEY stage (stage),
KEY run_id_post_id (run_id, post_id),
KEY created_status (created_at, response_status)
) {$charset_collate};";
// Content scores — post performance metrics for self-improvement.

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Private helpers for PRAutoBlogger_Generation_Checkpoint_Runner (v0.21.0, M4).
*
* Extracted to keep class-generation-checkpoint-runner.php under the 300-line
* cap (CONVENTIONS §1). Contains the three internal-only static methods:
* finalize() — write final transient, mark run done, release lock.
* cleanup_queue() — delete queue + run-id options on abort paths.
* fire_cron_now() — non-blocking loopback to fire cron events immediately.
*
* Only PRAutoBlogger_Generation_Checkpoint_Runner should call this class.
*
* @see core/class-generation-checkpoint-runner.php — Caller / orchestrator.
* @see ARCHITECTURE.md ss25 — Checkpoint design.
*/
class PRAutoBlogger_Generation_Checkpoint_Helpers {
/** Option key for the checkpoint queue (mirrors Runner constant). */
private const QUEUE_KEY = 'prautoblogger_article_queue';
/** Option key for the in-progress run UUID (mirrors Runner constant). */
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
/**
* Finalize a completed run: write final transient, mark run done, release lock.
*
* @param array $results Generation counters (generated/published/rejected/cost).
* @param string $run_id Run UUID (read from option if blank).
* @return void
*/
public static function finalize( array $results, string $run_id = '' ): void {
if ( '' === $run_id ) {
$run_id = (string) get_option( self::RUN_ID_KEY, '' );
}
if ( '' !== $run_id ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
}
PRAutoBlogger_Pipeline_Status::write_final( $results );
PRAutoBlogger_Pipeline_Status::log_summary( $results );
PRAutoBlogger_Generation_Lock::release();
delete_option( self::RUN_ID_KEY );
}
/** Remove the persistent queue and run-id option on abort paths. */
public static function cleanup_queue(): void {
delete_option( self::QUEUE_KEY );
delete_option( self::RUN_ID_KEY );
}
/**
* Non-blocking loopback to fire cron events immediately.
* Mirrors Pipeline_Runner::schedule_next() exactly.
*
* @return void
*/
public static function fire_cron_now(): void {
spawn_cron();
wp_remote_post(
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => false,
)
);
}
}

View File

@@ -0,0 +1,267 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Chained-cron checkpoint runner for in-admin manual article generation (v0.21.0, M4).
*
* What: Replaces the monolithic on_manual_generation() path with a two-tick
* checkpoint machine so Hostinger's background-PHP-process kill cannot
* abort a mid-pipeline run:
* Tick 1 (ORCHESTRATE): collect sources -> analyze patterns -> score ideas ->
* persist idea queue to option -> schedule Tick 2.
* Tick 2..N (GENERATE): pop one idea from queue -> Article_Worker ->
* update transient -> schedule next tick or finalize.
*
* Why separate from Rerun_Executor: Rerun_Executor targets existing run_stages
* rows (replay/rebuild). This class creates a brand-new run from scratch.
*
* SSH retirement (v0.21.0): the former `wp eval 'do_action("prautoblogger_manual_generation")'`
* workaround fired on_manual_generation() synchronously in one PHP process --
* bypassing the nonce and running the full pipeline without checkpoints.
* That path is retired: Executor::on_manual_generation() now delegates here,
* and `wp prautoblogger generate` queues via kick_off() (see ARCHITECTURE.md ss25).
*
* Cost governor: Run_State::ceiling_setting() is applied per-run; a halted run
* aborts all remaining ticks (same guarantee as Pipeline_Runner's queued-article path).
*
* Who triggers it: PRAutoBlogger_Executor::on_manual_generation() (cron),
* PRAutoBlogger_WP_CLI_Commands (wp prautoblogger generate).
* Dependencies: Pipeline_Runner (orchestration), Run_State (run ledger),
* Pipeline_Status (transient), Generation_Lock, Cost_Tracker, Logger.
*
* @see includes/class-executor.php -- Cron/AJAX entry points.
* @see core/class-pipeline-runner.php -- Orchestration + article worker.
* @see core/class-run-state.php -- Per-run ledger (cost governor).
* @see core/class-pipeline-status.php -- Transient broadcast helpers.
* @see core/class-generation-checkpoint-helpers.php -- finalize/cleanup/fire helpers.
* @see ARCHITECTURE.md ss25 -- Checkpoint generation path design.
*/
class PRAutoBlogger_Generation_Checkpoint_Runner {
/** Option key for the checkpoint queue (shared with Pipeline_Runner article queue). */
private const QUEUE_KEY = 'prautoblogger_article_queue';
/** Option key for the in-progress run UUID (generation tick). */
private const RUN_ID_KEY = 'prautoblogger_checkpoint_run_id';
/** WP-Cron action for the orchestration tick. */
public const ORCHESTRATE_ACTION = 'prautoblogger_gen_orchestrate';
/** WP-Cron action for each article-generation tick. */
public const GENERATE_ACTION = 'prautoblogger_gen_tick';
/**
* Schedule the orchestration tick and write the initial status transient.
* Called by on_ajax_generate_now() (via Executor) and by the WP-CLI command.
* Returns immediately -- ALL pipeline work happens in cron.
*
* Side effects: cron scheduling, transient write.
*
* @return void
*/
public static function kick_off(): void {
if ( wp_next_scheduled( self::ORCHESTRATE_ACTION ) ) {
return; // Idempotent: already queued.
}
wp_schedule_single_event( time(), self::ORCHESTRATE_ACTION );
PRAutoBlogger_Pipeline_Status::write_initial();
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
}
/**
* Cron Tick 1: collect -> analyze -> score. Persists ideas, creates run row,
* schedules Tick 2 for the first article, then returns.
*
* Side effects: source/analysis DB writes, run row insert, option write,
* status transient update, cron scheduling.
*
* @return void
*/
public static function on_orchestrate_tick(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
PRAutoBlogger_Pipeline_Status::write_error(
__( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' )
);
return;
}
$run_id = wp_generate_uuid4();
update_option( self::RUN_ID_KEY, $run_id, false );
PRAutoBlogger_Run_State::ensure_run( $run_id );
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( $run_id );
try {
if ( $cost_tracker->is_budget_exceeded() ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Pipeline_Status::write_error(
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
);
PRAutoBlogger_Generation_Lock::release();
return;
}
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Collecting sources…', 'prautoblogger' ) );
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
$ideas = $runner->orchestrate_only( $cost_tracker );
if ( empty( $ideas ) ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'done' );
PRAutoBlogger_Pipeline_Status::write_final(
array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
)
);
PRAutoBlogger_Generation_Lock::release();
delete_option( self::RUN_ID_KEY );
return;
}
$serialized = array_map(
static fn( PRAutoBlogger_Article_Idea $i ) => $i->to_array(),
$ideas
);
update_option(
self::QUEUE_KEY,
array(
'run_id' => $run_id,
'ideas' => $serialized,
'results' => array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
),
),
false
);
PRAutoBlogger_Pipeline_Status::broadcast(
sprintf(
/* translators: %d: number of ideas found */
__( 'Found %d idea(s) — starting generation…', 'prautoblogger' ),
count( $ideas )
)
);
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
wp_schedule_single_event( time(), self::GENERATE_ACTION );
}
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Orchestrate tick %s: %s', get_class( $e ), $e->getMessage() ),
'pipeline'
);
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
PRAutoBlogger_Generation_Lock::release();
delete_option( self::RUN_ID_KEY );
}
}
/**
* Cron Tick 2..N: generate one article, persist totals, reschedule or finalize.
*
* Side effects: LLM API calls, DB writes, post creation, transient updates,
* cron scheduling.
*
* @return void
*/
public static function on_generate_tick(): void {
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@ignore_user_abort( true );
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
@set_time_limit( 300 );
$queue = get_option( self::QUEUE_KEY );
if ( ! is_array( $queue ) || empty( $queue['ideas'] ) ) {
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize(
array(
'generated' => 0,
'published' => 0,
'rejected' => 0,
'cost' => 0.0,
)
);
return;
}
$run_id = (string) ( $queue['run_id'] ?? '' );
$run_status = PRAutoBlogger_Run_State::get_status( $run_id );
if ( in_array( $run_status, array( 'halted', 'failed' ), true ) ) {
PRAutoBlogger_Logger::instance()->warning(
sprintf( 'Generate tick: run %s is %s -- aborting.', $run_id, $run_status ),
'pipeline'
);
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
PRAutoBlogger_Generation_Lock::release();
return;
}
$idea_data = array_shift( $queue['ideas'] );
$idea = new PRAutoBlogger_Article_Idea( $idea_data );
$cost_tracker = new PRAutoBlogger_Cost_Tracker();
$cost_tracker->set_run_id( $run_id );
if ( $cost_tracker->is_budget_exceeded() ) {
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
PRAutoBlogger_Generation_Lock::release();
PRAutoBlogger_Pipeline_Status::write_error(
__( 'Monthly API budget exceeded. Generation halted.', 'prautoblogger' )
);
return;
}
// Persist consumed queue BEFORE generating (prevents orphan-recovery duplication).
if ( ! empty( $queue['ideas'] ) ) {
update_option( self::QUEUE_KEY, $queue, false );
} else {
delete_option( self::QUEUE_KEY );
}
try {
$worker = new PRAutoBlogger_Article_Worker( $cost_tracker );
$result = $worker->generate( $idea );
$queue['results']['generated'] += $result['generated'];
$queue['results']['published'] += $result['published'];
$queue['results']['rejected'] += $result['rejected'];
$queue['results']['cost'] += $result['cost'];
if ( ! empty( $queue['ideas'] ) ) {
update_option( self::QUEUE_KEY, $queue, false );
PRAutoBlogger_Pipeline_Status::update_queue_progress( $queue );
if ( ! wp_next_scheduled( self::GENERATE_ACTION ) ) {
wp_schedule_single_event( time(), self::GENERATE_ACTION );
}
PRAutoBlogger_Generation_Checkpoint_Helpers::fire_cron_now();
} else {
PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id );
PRAutoBlogger_Generation_Checkpoint_Helpers::finalize( $queue['results'], $run_id );
}
} catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error(
sprintf( 'Generate tick %s for run %s: %s', get_class( $e ), $run_id, $e->getMessage() ),
'pipeline'
);
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
PRAutoBlogger_Generation_Checkpoint_Helpers::cleanup_queue();
PRAutoBlogger_Generation_Lock::release();
PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() );
}
}
}

View File

@@ -168,6 +168,19 @@ class PRAutoBlogger_Pipeline_Runner {
}
}
/**
* Public wrapper for orchestrate(): collect -> analyze -> score.
* Used by Generation_Checkpoint_Runner (Tick 1) so orchestration and
* article generation happen in separate cron ticks.
*
* @param PRAutoBlogger_Cost_Tracker $cost_tracker
* @return PRAutoBlogger_Article_Idea[]
*/
public function orchestrate_only( PRAutoBlogger_Cost_Tracker $cost_tracker ): array {
return $this->orchestrate( $cost_tracker );
}
// ── Private helpers ─────────────────────────────────────────────────
/**

View File

@@ -103,4 +103,41 @@ class PRAutoBlogger_Pipeline_Status {
'pipeline'
);
}
/**
* Write an initial "running" status before the first tick fires.
* Mirrors the shape on_ajax_generate_now() already writes so the poller
* sees a consistent "running" state whether triggered from board or settings page.
*
* @return void
*/
public static function write_initial(): void {
set_transient(
self::KEY,
array(
'status' => 'running',
'stage' => __( 'Starting generation…', 'prautoblogger' ),
'started' => time(),
'last_updated' => time(),
),
self::TTL
);
}
/**
* Write a terminal "error" status.
*
* @param string $message Operator-facing error description.
* @return void
*/
public static function write_error( string $message ): void {
set_transient(
self::KEY,
array(
'status' => 'error',
'message' => $message,
),
self::TTL
);
}
}

View File

@@ -9,7 +9,7 @@
* 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.20.1
* Version: 0.21.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,10 +35,14 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.20.1' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' );
define( 'PRAUTOBLOGGER_VERSION', '0.21.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' );
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
// Default max cards shown per board column and per ideas-browser page.
// Settings-backed (prautoblogger_board_column_limit, prautoblogger_ideas_per_page).
define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
define( 'PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE', 30 );
// v0.18.1: ceiling on "thinking" tokens per reasoning-enabled LLM call. The
// request's completion budget (max_tokens) is RAISED by this amount so
// reasoning can never consume the visible-content budget and return an empty

View File

@@ -9,9 +9,9 @@
* kept a fallback copy, masking the gap until prod boot.
*
* Usage (from repo root):
* php scripts/check-constants.php [--verbose]
* php scripts/check-constants.php [--verbose] [--self-test]
*
* Exit 0 = all clear. Exit 1 = unguarded undefined constants found.
* Exit 0 = all clear. Exit 1 = unguarded undefined constants found. Exit 2 = --self-test failed.
*
* Include/exclude summary (relative to repo root):
* Defined-set source prautoblogger.php only
@@ -29,7 +29,7 @@
declare(strict_types=1);
// How many lines above the bare use to look for a defined() guard.
const CONSTANTS_GUARD_WINDOW = 4;
const CONSTANTS_GUARD_WINDOW = 4; // 4 covers if/elseif blocks and multi-line && chains (wider risks false-positives).
// Source of truth for define()d constants -- test-bootstrap fallbacks excluded.
const CONSTANTS_BOOTSTRAP_FILE = 'prautoblogger.php';
@@ -45,7 +45,8 @@ const CONSTANTS_SCAN_ROOTS = [
'templates',
];
$verbose = in_array( '--verbose', $argv ?? [], true );
$verbose = in_array( '--verbose', $argv ?? [], true );
$self_test = in_array( '--self-test', $argv ?? [], true );
// -------------------------------------------------------------------------
// 1. Build the defined set from prautoblogger.php only.
@@ -208,5 +209,44 @@ if ( $errors !== [] ) {
exit( 1 );
}
// -------------------------------------------------------------------------
// 5. Self-test mode: synthesize a missing constant and assert non-zero exit.
// -------------------------------------------------------------------------
if ( $self_test ) {
// Temporarily inject a fake reference that must NOT be in prautoblogger.php.
// The scan above will flag PRAUTOBLOGGER_SELFTEST_SENTINEL as unguarded.
// We verify this script would have exited non-zero in that scenario.
$sentinel = 'PRAUTOBLOGGER_SELFTEST_SENTINEL';
$bootstrap_src = (string) file_get_contents( CONSTANTS_BOOTSTRAP_FILE );
if ( false !== strpos( $bootstrap_src, $sentinel ) ) {
fwrite( STDERR, "SELF-TEST FAIL: sentinel constant already defined in prautoblogger.php -- pick a unique name.\n" );
exit( 2 );
}
// If $errors is empty at this point, the real codebase is clean (good).
// Now simulate an unguarded reference by re-running the scan on a temp file.
$tmp_file = tempnam( sys_get_temp_dir(), 'prab_selftest_' ) . '.php';
file_put_contents( $tmp_file, "<?php\necho {$sentinel};\n" );
$tmp_errors = [];
$tmp_lines = explode( "\n", (string) file_get_contents( $tmp_file ) );
foreach ( $tmp_lines as $idx => $raw_line ) {
if ( preg_match_all( '/PRAUTOBLOGGER_[A-Z0-9_]+/', $raw_line, $m ) ) {
foreach ( $m[0] as $n ) {
if ( ! isset( $defined_set[ $n ] ) ) {
$tmp_errors[] = $n;
}
}
}
}
@unlink( $tmp_file );
if ( ! in_array( $sentinel, $tmp_errors, true ) ) {
fwrite( STDERR, "SELF-TEST FAIL: scanner did not detect unguarded sentinel constant.\n" );
exit( 2 );
}
echo "SELF-TEST OK -- scanner correctly detected the synthetic missing constant.\n";
exit( 0 );
}
echo "OK -- All PRAUTOBLOGGER_* constants are production-defined or defined()-guarded.\n";
exit( 0 );

View File

@@ -98,7 +98,16 @@ function prab_render_board_card( array $card, string $column_key ): void {
<h1 class="prab-board-heading">
<?php esc_html_e( 'PRAutoBlogger', 'prautoblogger' ); ?>
<span class="prab-board-heading-sub"><?php esc_html_e( 'Article Board', 'prautoblogger' ); ?></span>
<?php if ( current_user_can( 'manage_options' ) ) : ?>
<button type="button"
id="prab-board-generate-now"
class="prab-board-generate-btn button button-primary"
aria-label="<?php esc_attr_e( 'Generate a new article now', 'prautoblogger' ); ?>">
<?php esc_html_e( 'New Article', 'prautoblogger' ); ?>
</button>
<?php endif; ?>
</h1>
<div id="prab-board-gen-status" class="prab-board-gen-status" aria-live="polite" style="display:none;"></div>
<div id="prab-board" class="prab-board" aria-live="polite" aria-label="<?php esc_attr_e( 'Article generation board', 'prautoblogger' ); ?>">

View File

@@ -209,6 +209,11 @@ if ( ! class_exists( 'WP_Post' ) ) {
// Plugin URL and version constants used by admin enqueue methods.
// Defined here so all tests that load admin classes can stub WP enqueue calls
// without hitting "Undefined constant" errors.
if ( ! defined( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT' ) ) {
define( 'PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT', 20 );
}
// Plugin URL and version constants used by admin enqueue methods.
if ( ! defined( 'PRAUTOBLOGGER_PLUGIN_URL' ) ) {
define( 'PRAUTOBLOGGER_PLUGIN_URL', 'https://test.example.com/wp-content/plugins/prautoblogger/' );
}

View File

@@ -0,0 +1,239 @@
<?php
/**
* Tests for PRAutoBlogger_Board_Gen_Log_Query — binding order and result shape.
*
* The key regression: both get_generating_cards() and get_failed_cards() had
* (a) a missing comma between bound args and (b) reversed argument order — the
* integer limit was passed first, the datetime string second, which bound
* created_at >= <int> and LIMIT <datetime>. These tests assert the correct
* binding order (string first, int second) so any future regression is caught
* before CI.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class BoardGenLogQueryTest extends BaseTestCase {
/**
* Captured prepare() calls: each entry is ['sql' => string, 'args' => array].
*
* @var array<int, array{sql: string, args: array}>
*/
private array $prepare_calls = array();
/**
* Build a fresh mock $wpdb whose get_results() always returns $rows.
*
* @param array $rows Rows to return from get_results().
* @return \PHPUnit\Framework\MockObject\MockObject
*/
private function make_wpdb( array $rows = array() ): object {
$wpdb = $this->create_mock_wpdb();
$wpdb->prefix = 'wp_';
$GLOBALS['wpdb'] = $wpdb;
$self = $this;
$wpdb->method( 'prepare' )->willReturnCallback(
static function ( string $sql, ...$args ) use ( $self ) {
if ( 1 === count( $args ) && is_array( $args[0] ) ) {
$args = $args[0];
}
$self->prepare_calls[] = array( 'sql' => $sql, 'args' => $args );
return 'PREPARED_SQL';
}
);
$wpdb->method( 'get_results' )->willReturn( $rows );
return $wpdb;
}
protected function setUp(): void {
parent::setUp();
$this->prepare_calls = array();
Functions\when( 'get_transient' )->justReturn( false );
Functions\when( 'admin_url' )->alias(
function ( string $path = '' ) {
return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
}
);
Functions\when( 'get_option' )->alias(
function ( string $name, $default = false ) {
if ( 'prautoblogger_board_column_limit' === $name ) {
return 10;
}
return $default;
}
);
}
protected function tearDown(): void {
$this->prepare_calls = array();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
// ── get_generating_cards() binding order ────────────────────────────────
/**
* get_generating_cards() must pass $since (string) as the FIRST bound arg
* and the integer limit as the SECOND — matching placeholder order %s … %d.
*
* This would have caught the reversed-order bug (limit int passed first,
* datetime string second) that silently bound created_at >= <int>.
*/
public function test_generating_cards_prepare_arg_order(): void {
$this->make_wpdb();
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$query->get_generating_cards();
// Find the prepare() call containing LIMIT %d (the gen-log secondary query).
$gen_call = null;
foreach ( $this->prepare_calls as $call ) {
if (
str_contains( $call['sql'], 'LIMIT %d' ) &&
str_contains( $call['sql'], 'post_id IS NULL' ) &&
! str_contains( $call['sql'], "response_status = 'error'" )
) {
$gen_call = $call;
break;
}
}
$this->assertNotNull( $gen_call, 'get_generating_cards() must call $wpdb->prepare() with LIMIT %d' );
$this->assertCount( 2, $gen_call['args'], 'prepare() must receive exactly 2 bound args' );
// First arg must be a string (the $since datetime).
$this->assertIsString( $gen_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
// Second arg must be an integer (the column limit).
$this->assertIsInt( $gen_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
}
/**
* get_generating_cards() must return an empty array (not error) when DB returns nothing.
*/
public function test_generating_cards_returns_empty_array_on_no_results(): void {
$this->make_wpdb();
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$cards = $query->get_generating_cards();
$this->assertIsArray( $cards );
}
/**
* When the status transient signals a running job, get_generating_cards()
* returns a card from the transient (no DB call needed for that card).
*/
public function test_generating_cards_returns_transient_card_when_running(): void {
$this->make_wpdb();
Functions\when( 'get_transient' )->alias(
function ( $key ) {
if ( 'prautoblogger_generation_status' === $key ) {
return array(
'status' => 'running',
'stage' => 'Drafting…',
'started' => time() - 30,
);
}
return false;
}
);
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$cards = $query->get_generating_cards();
$this->assertNotEmpty( $cards, 'Transient running state must produce at least one card' );
$this->assertSame( '', $cards[0]['run_id'], 'Transient card has empty run_id' );
$this->assertSame( 'logs', $cards[0]['click_action'] );
}
// ── get_failed_cards() binding order ────────────────────────────────────
/**
* get_failed_cards() must pass $since (string) as the FIRST bound arg
* and the integer limit as the SECOND — matching placeholder order %s … %d.
*/
public function test_failed_cards_prepare_arg_order(): void {
$this->make_wpdb();
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$query->get_failed_cards();
// Find the prepare() call for the failed query.
$fail_call = null;
foreach ( $this->prepare_calls as $call ) {
if ( str_contains( $call['sql'], "response_status = 'error'" ) ) {
$fail_call = $call;
break;
}
}
$this->assertNotNull( $fail_call, 'get_failed_cards() must call $wpdb->prepare() with response_status filter' );
$this->assertCount( 2, $fail_call['args'], 'prepare() must receive exactly 2 bound args' );
// First arg must be a string (the $since datetime).
$this->assertIsString( $fail_call['args'][0], 'First arg to prepare() must be the $since string (for %s)' );
// Second arg must be an integer (the column limit).
$this->assertIsInt( $fail_call['args'][1], 'Second arg to prepare() must be the integer limit (for %d)' );
}
/**
* get_failed_cards() returns the correct card shape from a seeded result row.
*/
public function test_failed_cards_returns_correct_shape(): void {
// Use a fresh wpdb with non-empty get_results rows.
$this->make_wpdb(
array(
array(
'run_id' => 'fail-run-abc',
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
'last_error' => 'Rate limit exceeded',
'last_stage' => 'draft',
'call_count' => '3',
),
)
);
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$cards = $query->get_failed_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 'fail-run-abc', $cards[0]['run_id'] );
$this->assertStringContainsString( 'Rate limit', $cards[0]['error_message'] );
$this->assertSame( 'logs', $cards[0]['click_action'] );
$this->assertArrayHasKey( 'started_at', $cards[0] );
}
/**
* Column-limit option is honoured: limit 7 → second arg to prepare() is 7.
*/
public function test_column_limit_option_is_respected(): void {
Functions\when( 'get_option' )->alias(
function ( string $name, $default = false ) {
if ( 'prautoblogger_board_column_limit' === $name ) {
return 7;
}
return $default;
}
);
$this->make_wpdb();
$query = new \PRAutoBlogger_Board_Gen_Log_Query();
$query->get_generating_cards();
$gen_call = null;
foreach ( $this->prepare_calls as $call ) {
if ( str_contains( $call['sql'], 'LIMIT %d' ) && ! str_contains( $call['sql'], "response_status = 'error'" ) ) {
$gen_call = $call;
break;
}
}
$this->assertNotNull( $gen_call );
$this->assertSame( 7, $gen_call['args'][1], 'Limit arg must match the option value' );
}
}

View File

@@ -54,6 +54,15 @@ class BoardHumanModifiedFlagTest extends BaseTestCase {
);
Functions\when( 'get_edit_post_link' )->justReturn( 'https://example.com/edit' );
Functions\when( 'admin_url' )->alias( static fn( $p = '' ) => 'https://example.com/wp-admin/' . $p );
Functions\when( 'get_option' )->alias(
function ( string $name, $default = false ) {
$map = array(
'prautoblogger_board_column_limit' => PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT,
'prautoblogger_board_published_window_days' => 7,
);
return $map[ $name ] ?? $default;
}
);
\PRAutoBlogger_Run_Stage_State::flush_cache();
}

View File

@@ -0,0 +1,230 @@
<?php
/**
* Tests for PRAutoBlogger_Generation_Checkpoint_Runner (v0.21.0, M4).
*
* Locks the chained-cron checkpoint contract:
* kick_off() -- schedules cron + writes initial transient, no pipeline call.
* on_generate_tick() -- pops ONE idea, reschedules or finalizes; halted run aborts early.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class GenerationCheckpointRunnerTest extends BaseTestCase {
/** @var string[] Cron hooks captured from wp_schedule_single_event(). */
private array $scheduled = array();
/** @var array<string, mixed> Option writes captured from update_option(). */
private array $options = array();
/** @var array<int, array> Transient writes captured from set_transient(). */
private array $transients = array();
protected function setUp(): void {
parent::setUp();
$this->scheduled = array();
$this->options = array();
$this->transients = array();
Functions\when( 'wp_schedule_single_event' )->alias(
function ( $ts, $hook, $args = array() ) {
$this->scheduled[] = $hook;
return true;
}
);
Functions\when( 'wp_next_scheduled' )->justReturn( false );
Functions\when( 'spawn_cron' )->justReturn( true );
Functions\when( 'wp_remote_post' )->justReturn( array() );
Functions\when( 'site_url' )->justReturn( 'https://example.com' );
Functions\when( 'set_transient' )->alias(
function ( $key, $value, $ttl = 0 ) {
$this->transients[] = array( 'key' => $key, 'value' => (array) $value );
return true;
}
);
Functions\when( 'update_option' )->alias(
function ( $name, $value, $autoload = null ) {
$this->options[ $name ] = $value;
return true;
}
);
Functions\when( 'delete_option' )->alias(
function ( $name ) {
unset( $this->options[ $name ] );
return true;
}
);
Functions\when( 'get_option' )->alias(
function ( $name, $default = false ) {
return $this->options[ $name ] ?? $default;
}
);
Functions\when( 'wp_generate_uuid4' )->justReturn( 'test-run-uuid-001' );
// Monthly budget 0 = no limit.
$this->options['prautoblogger_monthly_budget_usd'] = '0';
Functions\when( 'wp_cache_get' )->justReturn( false );
Functions\when( 'wp_cache_set' )->justReturn( true );
Functions\when( 'wp_cache_delete' )->justReturn( true );
\PRAutoBlogger_Run_State::flush_cache();
}
protected function tearDown(): void {
\PRAutoBlogger_Run_State::flush_cache();
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/** Stub the generation lock to always succeed. */
private function wire_wpdb_for_lock(): void {
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $wpdb;
$wpdb->method( 'query' )->willReturn( 1 );
$wpdb->method( 'get_var' )->willReturn( null );
}
/** Build a minimal Article_Idea-compatible array. */
private function make_idea( string $topic = 'Test Topic' ): array {
return array(
'topic' => $topic,
'article_type' => 'guide',
'suggested_title' => 'Guide to ' . $topic,
'summary' => '',
'score' => 0.9,
'analysis_id' => 1,
'source_ids' => array(),
'key_points' => array(),
'target_keywords' => array(),
);
}
// ── kick_off() ────────────────────────────────────────────────────────
/**
* kick_off() schedules exactly the ORCHESTRATE cron action and writes
* an initial 'running' status transient. The pipeline is NOT called.
*/
public function test_kick_off_schedules_orchestrate_cron_and_writes_initial_transient(): void {
\PRAutoBlogger_Generation_Checkpoint_Runner::kick_off();
$this->assertContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::ORCHESTRATE_ACTION,
$this->scheduled,
'kick_off() must schedule ORCHESTRATE_ACTION'
);
$found_running_transient = false;
foreach ( $this->transients as $t ) {
if ( isset( $t['value']['status'] ) && 'running' === $t['value']['status'] ) {
$found_running_transient = true;
break;
}
}
$this->assertTrue( $found_running_transient, 'kick_off() must write a running status transient' );
}
// ── on_generate_tick() ────────────────────────────────────────────────
/**
* An empty queue causes generate tick to finalize without scheduling
* another GENERATE action (no Article_Worker call).
*/
public function test_generate_tick_finalizes_when_queue_is_empty(): void {
// No queue option => get_option returns false (default from setUp alias).
// wire_wpdb_for_lock() provides $wpdb so Helpers::finalize() ->
// Generation_Lock::release() can run without "Call to member on null".
$this->wire_wpdb_for_lock();
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
$this->assertNotContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
$this->scheduled,
'Empty queue must not reschedule GENERATE'
);
}
/**
* A halted run causes generate tick to abort: queue is cleaned up and
* GENERATE is not rescheduled (Article_Worker would not be called again).
*/
public function test_generate_tick_aborts_on_halted_run_without_rescheduling(): void {
$run_id = 'halted-run-uuid';
$this->options['prautoblogger_article_queue'] = array(
'run_id' => $run_id,
'ideas' => array( $this->make_idea() ),
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
);
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $wpdb;
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'halted', 'ceiling_usd' => '0' ) );
$wpdb->method( 'query' )->willReturn( 1 );
$wpdb->method( 'get_var' )->willReturn( null );
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
$this->assertArrayNotHasKey(
'prautoblogger_article_queue',
$this->options,
'Halted run must trigger queue cleanup'
);
$this->assertNotContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
$this->scheduled,
'Halted run must not reschedule GENERATE'
);
}
/**
* With 2 ideas in queue, first generate tick pops one, reschedules GENERATE,
* and leaves 1 idea remaining in the persisted queue.
*/
public function test_generate_tick_pops_one_idea_and_reschedules_when_more_remain(): void {
$run_id = 'multi-idea-run-uuid';
$this->options['prautoblogger_article_queue'] = array(
'run_id' => $run_id,
'ideas' => array( $this->make_idea( 'Idea A' ), $this->make_idea( 'Idea B' ) ),
'results' => array( 'generated' => 0, 'published' => 0, 'rejected' => 0, 'cost' => 0.0 ),
);
$this->options['prautoblogger_checkpoint_run_id'] = $run_id;
$wpdb = $this->create_mock_wpdb();
$wpdb->options = 'wp_options';
$GLOBALS['wpdb'] = $wpdb;
$wpdb->method( 'get_row' )->willReturn( array( 'status' => 'running', 'ceiling_usd' => '0' ) );
$wpdb->method( 'query' )->willReturn( 1 );
$wpdb->method( 'get_var' )->willReturn( null );
// Provide stub Article_Worker so CI doesn't need real LLM dependencies.
if ( ! class_exists( 'PRAutoBlogger_Article_Worker', false ) ) {
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
eval( 'class PRAutoBlogger_Article_Worker {
public function __construct($ct){}
public function generate($idea){ return ["generated"=>1,"published"=>1,"rejected"=>0,"cost"=>0.01]; }
}' );
}
if ( ! class_exists( 'PRAutoBlogger_Post_Assembler', false ) ) {
// phpcs:ignore Squiz.Strings.DoubleQuoteUsage.NotRequired
eval( 'class PRAutoBlogger_Post_Assembler { public static function amortize_research_costs($r){} }' );
}
\PRAutoBlogger_Generation_Checkpoint_Runner::on_generate_tick();
$this->assertContains(
\PRAutoBlogger_Generation_Checkpoint_Runner::GENERATE_ACTION,
$this->scheduled,
'More ideas remaining: GENERATE must be rescheduled'
);
if ( isset( $this->options['prautoblogger_article_queue'] ) ) {
$remaining = $this->options['prautoblogger_article_queue']['ideas'] ?? array();
$this->assertCount( 1, $remaining, 'Queue must have exactly 1 idea left after first tick' );
}
}
}

View File

@@ -97,6 +97,9 @@ $hooks = array(
'prautoblogger_opik_dispatch',
'prautoblogger_rerun_stage_replay',
'prautoblogger_rerun_from_stage',
// v0.21.0 (M4): chained-cron checkpoint ticks.
'prautoblogger_gen_orchestrate',
'prautoblogger_gen_tick',
);
foreach ( $hooks as $hook ) {