From c7f63b1133e25f2121ed4af366c7364ce57daffe Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Sun, 14 Jun 2026 13:33:49 +0000 Subject: [PATCH] feat: M4 chained-cron checkpoint generation + board button (v0.21.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Board 'New Article' button - templates/admin/board-page.php: add #prab-board-generate-now button + status div - includes/admin/class-board-page.php: inject generateNonce + i18n strings into prabBoard; enqueue companion board-generate.js - assets/js/board-generate.js: NEW – board button AJAX handler (fires prautoblogger_generate_now, resets board backoff on success) - assets/js/board.js: applySnapshot() re-enables button when no active runs ## Chained-cron checkpoint machine (retires SSH workaround) - includes/core/class-generation-checkpoint-runner.php: NEW (295 lines) Tick 1 (prautoblogger_gen_orchestrate): orchestrate_only() → persist queue → schedule Tick 2 Tick 2..N (prautoblogger_gen_tick): pop one idea → Article_Worker → reschedule or finalize Budget/halt contract + lock release on all exit paths - includes/class-executor.php: on_manual_generation() replaced with 1-line delegate to Generation_Checkpoint_Runner::on_orchestrate_tick() (SSH workaround retired) - includes/class-prautoblogger.php: register ORCHESTRATE_ACTION + GENERATE_ACTION hooks - includes/core/class-pipeline-runner.php: expose orchestrate_only() public wrapper - includes/core/class-pipeline-status.php: add write_initial() + write_error() helpers ## WP-CLI command - includes/admin/class-wp-cli-commands.php: wp prautoblogger generate → kick_off() ## IA / perf pass (settings-backed pagination + DB indexes) - includes/admin/class-board-data-provider.php: replace hardcoded posts_per_page=20 with get_option('prautoblogger_board_column_limit', DEFAULT) - includes/admin/class-board-gen-log-query.php: replace LIMIT 10/20 literals with settings-backed values - includes/admin/class-ideas-browser-query.php: NEW – query helpers extracted from Ideas_Browser (300-line rule split): query_ideas(), get_type_counts(), load_idea_data(), find_post_by_run_id(), update_idea_stage() - includes/admin/class-ideas-browser.php: delegates all query work to Ideas_Browser_Query - includes/class-activator.php: set_default_options() adds board_column_limit + ideas_per_page - includes/admin/class-admin-page.php: add new settings to numeric sanitization list - prautoblogger.php: PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT=20, PRAUTOBLOGGER_DEFAULT_IDEAS_PER_PAGE=30 - includes/class-schema-installer.php: indexes on generation_log + analysis_results (db 1.4.0) - includes/class-pipeline-schema-installer.php: KEY started_at on runs table ## Guard P2s - scripts/check-constants.php: --self-test mode + inline CONSTANTS_GUARD_WINDOW comment - .github/workflows/ci.yml: check-constants-self-test step after main constants check ## PHPUnit test - tests/unit/Core/GenerationCheckpointRunnerTest.php: NEW (227 lines) 4 tests: kick_off schedules+transient, empty-queue finalizes, halted-run aborts+cleanup, multi-idea pops-one-and-reschedules ## Docs + version - prautoblogger.php: Version 0.21.0, PRAUTOBLOGGER_DB_VERSION 1.4.0 - ARCHITECTURE.md: §25 Chained-Cron Checkpoint Generation Path - CHANGELOG.md: v0.21.0 entry - INFORMATION-ARCHITECTURE.md: NEW – admin pages, entry points, DB tables, options, CLI - uninstall.php: purge prautoblogger_gen_orchestrate + prautoblogger_gen_tick cron hooks Agent-Session: phase2-admin-m4-20260614 --- .github/workflows/ci.yml | 4 + ARCHITECTURE.md | 62 ++++ CHANGELOG.md | 55 ++++ INFORMATION-ARCHITECTURE.md | 84 +++++ assets/js/board-generate.js | 53 ++++ assets/js/board.js | 20 +- includes/admin/class-admin-page.php | 2 +- includes/admin/class-board-data-provider.php | 4 +- includes/admin/class-board-gen-log-query.php | 6 +- includes/admin/class-board-page.php | 14 +- includes/admin/class-ideas-browser-query.php | 136 ++++++++ includes/admin/class-ideas-browser.php | 191 ++---------- includes/admin/class-wp-cli-commands.php | 32 ++ includes/class-activator.php | 5 +- includes/class-executor.php | 70 +---- includes/class-pipeline-schema-installer.php | 3 +- includes/class-prautoblogger.php | 4 + includes/class-schema-installer.php | 7 +- .../class-generation-checkpoint-runner.php | 295 ++++++++++++++++++ includes/core/class-pipeline-runner.php | 13 + includes/core/class-pipeline-status.php | 38 +++ prautoblogger.php | 10 +- scripts/check-constants.php | 48 ++- templates/admin/board-page.php | 9 + .../Core/GenerationCheckpointRunnerTest.php | 227 ++++++++++++++ uninstall.php | 3 + 26 files changed, 1145 insertions(+), 250 deletions(-) create mode 100644 INFORMATION-ARCHITECTURE.md create mode 100644 assets/js/board-generate.js create mode 100644 includes/admin/class-ideas-browser-query.php create mode 100644 includes/core/class-generation-checkpoint-runner.php create mode 100644 tests/unit/Core/GenerationCheckpointRunnerTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8d48a5..f853c39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 581ad5f..aba3fe4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dfa494..720f3fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/INFORMATION-ARCHITECTURE.md b/INFORMATION-ARCHITECTURE.md new file mode 100644 index 0000000..d8f9fc6 --- /dev/null +++ b/INFORMATION-ARCHITECTURE.md @@ -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)* diff --git a/assets/js/board-generate.js b/assets/js/board-generate.js new file mode 100644 index 0000000..2cd6833 --- /dev/null +++ b/assets/js/board-generate.js @@ -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 ) ); diff --git a/assets/js/board.js b/assets/js/board.js index a58f31a..f103e93 100644 --- a/assets/js/board.js +++ b/assets/js/board.js @@ -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 = $( '' ); - $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 ) ); diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index e9eb6c0..a8bbfad 100644 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -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; } diff --git a/includes/admin/class-board-data-provider.php b/includes/admin/class-board-data-provider.php index 1b4c7ec..17347ed 100644 --- a/includes/admin/class-board-data-provider.php +++ b/includes/admin/class-board-data-provider.php @@ -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, diff --git a/includes/admin/class-board-gen-log-query.php b/includes/admin/class-board-gen-log-query.php index 4f9f278..7dc5a10 100644 --- a/includes/admin/class-board-gen-log-query.php +++ b/includes/admin/class-board-gen-log-query.php @@ -76,7 +76,8 @@ class PRAutoBlogger_Board_Gen_Log_Query { AND post_id IS NULL GROUP BY run_id ORDER BY started_at DESC - LIMIT 10", + LIMIT %d", + (int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ) $since ), ARRAY_A @@ -148,7 +149,8 @@ class PRAutoBlogger_Board_Gen_Log_Query { AND post_id IS NULL GROUP BY run_id ORDER BY started_at DESC - LIMIT 20", + LIMIT %d", + (int) max( 5, get_option( 'prautoblogger_board_column_limit', PRAUTOBLOGGER_DEFAULT_BOARD_COLUMN_LIMIT ) ) $since ), ARRAY_A diff --git a/includes/admin/class-board-page.php b/includes/admin/class-board-page.php index 4b1e3c0..28a557d 100644 --- a/includes/admin/class-board-page.php +++ b/includes/admin/class-board-page.php @@ -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, @@ -152,7 +160,11 @@ class PRAutoBlogger_Board_Page { 'edit' => __( 'Edit', 'prautoblogger' ), 'viewLog' => __( 'View Log', 'prautoblogger' ), 'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ), - 'humanModified' => __( 'Human-modified', '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' ), ), ) ); diff --git a/includes/admin/class-ideas-browser-query.php b/includes/admin/class-ideas-browser-query.php new file mode 100644 index 0000000..df4a046 --- /dev/null +++ b/includes/admin/class-ideas-browser-query.php @@ -0,0 +1,136 @@ +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|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 ); + } + } +} diff --git a/includes/admin/class-ideas-browser.php b/includes/admin/class-ideas-browser.php index 9f1aa1d..e0e0f98 100644 --- a/includes/admin/class-ideas-browser.php +++ b/includes/admin/class-ideas-browser.php @@ -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,29 +147,18 @@ 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', - 'message' => 'Idea data expired.', - ), - self::STATUS_TTL - ); + set_transient( $key, array( 'status' => 'error', 'message' => 'Idea data expired.' ), $ttl ); return; } if ( ! PRAutoBlogger_Generation_Lock::acquire() ) { - set_transient( - $key, - array( - 'status' => 'error', - 'message' => 'Another generation is running.', - ), - self::STATUS_TTL - ); + set_transient( $key, array( 'status' => 'error', 'message' => 'Another generation is running.' ), $ttl ); return; } @@ -180,7 +167,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 +175,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,139 +186,17 @@ class PRAutoBlogger_Ideas_Browser { 'cost' => $result['cost'], 'post_id' => $post_id, ), - self::STATUS_TTL + $ttl ); } catch ( \Throwable $e ) { PRAutoBlogger_Logger::instance()->error( sprintf( 'Idea generation %s for #%d: %s', get_class( $e ), $idea_id, $e->getMessage() ), 'pipeline' ); - set_transient( - $key, - array( - 'status' => 'error', - 'message' => $e->getMessage(), - ), - self::STATUS_TTL - ); + set_transient( $key, array( 'status' => 'error', 'message' => $e->getMessage() ), $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|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 ); } } diff --git a/includes/admin/class-wp-cli-commands.php b/includes/admin/class-wp-cli-commands.php index d9f664f..9b0c96e 100644 --- a/includes/admin/class-wp-cli-commands.php +++ b/includes/admin/class-wp-cli-commands.php @@ -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,28 @@ 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.' ); + } + } diff --git a/includes/class-activator.php b/includes/class-activator.php index 7892977..4f6d98c 100644 --- a/includes/class-activator.php +++ b/includes/class-activator.php @@ -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 ) { diff --git a/includes/class-executor.php b/includes/class-executor.php index fb2d1e0..ad97617 100644 --- a/includes/class-executor.php +++ b/includes/class-executor.php @@ -91,62 +91,22 @@ class PRAutoBlogger_Executor { * chained cron events — each in its own PHP process. The lock is released * by the pipeline when the last article completes, not here. */ + /** + * Cron handler: kick off the chained-cron orchestration tick (v0.21.0, M4). + * + * 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. */ diff --git a/includes/class-pipeline-schema-installer.php b/includes/class-pipeline-schema-installer.php index 3ceee7c..e0fafe4 100644 --- a/includes/class-pipeline-schema-installer.php +++ b/includes/class-pipeline-schema-installer.php @@ -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 diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 9ebf6a9..ca74d4f 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -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' ) ); diff --git a/includes/class-schema-installer.php b/includes/class-schema-installer.php index ec61810..e808944 100644 --- a/includes/class-schema-installer.php +++ b/includes/class-schema-installer.php @@ -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. diff --git a/includes/core/class-generation-checkpoint-runner.php b/includes/core/class-generation-checkpoint-runner.php new file mode 100644 index 0000000..768c626 --- /dev/null +++ b/includes/core/class-generation-checkpoint-runner.php @@ -0,0 +1,295 @@ + 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 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(); + self::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 ); + } + self::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'] ) ) { + self::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' + ); + self::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' ); + self::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 ); + } + self::fire_cron_now(); + } else { + PRAutoBlogger_Post_Assembler::amortize_research_costs( $run_id ); + self::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' ); + self::cleanup_queue(); + PRAutoBlogger_Generation_Lock::release(); + PRAutoBlogger_Pipeline_Status::write_error( $e->getMessage() ); + } + } + + // -- Private helpers -------------------------------------------------- + + /** + * 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 + */ + private 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. */ + private 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 + */ + private 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, + ) + ); + } +} diff --git a/includes/core/class-pipeline-runner.php b/includes/core/class-pipeline-runner.php index 426c373..b19044e 100644 --- a/includes/core/class-pipeline-runner.php +++ b/includes/core/class-pipeline-runner.php @@ -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 ───────────────────────────────────────────────── /** diff --git a/includes/core/class-pipeline-status.php b/includes/core/class-pipeline-status.php index 6e7d01a..4a654c5 100644 --- a/includes/core/class-pipeline-status.php +++ b/includes/core/class-pipeline-status.php @@ -103,4 +103,42 @@ 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 + ); + } + } diff --git a/prautoblogger.php b/prautoblogger.php index 7d297c1..97ea22f 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -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 diff --git a/scripts/check-constants.php b/scripts/check-constants.php index 6b0b9b6..2d36670 100644 --- a/scripts/check-constants.php +++ b/scripts/check-constants.php @@ -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, " $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 ); diff --git a/templates/admin/board-page.php b/templates/admin/board-page.php index 318db30..8f3db0f 100644 --- a/templates/admin/board-page.php +++ b/templates/admin/board-page.php @@ -98,7 +98,16 @@ function prab_render_board_card( array $card, string $column_key ): void {

+ + +

+
diff --git a/tests/unit/Core/GenerationCheckpointRunnerTest.php b/tests/unit/Core/GenerationCheckpointRunnerTest.php new file mode 100644 index 0000000..ed54542 --- /dev/null +++ b/tests/unit/Core/GenerationCheckpointRunnerTest.php @@ -0,0 +1,227 @@ + Option writes captured from update_option(). */ + private array $options = array(); + + /** @var 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). + \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' ); + } + } +} diff --git a/uninstall.php b/uninstall.php index 784ae84..ddfeed1 100644 --- a/uninstall.php +++ b/uninstall.php @@ -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 ) {