feat: kanban board dashboard + SQL bug fix (v0.19.0)

- Add kanban board as primary admin landing screen (M1, Phase 2)
- Four columns: Generating / In Review / Published / Failed
- Live AJAX polling (prautoblogger_board_status), settings-backed interval + window
- Fix cost-reporter SQL inline-comment bug in get_daily_spend + get_spend_by_stage
- New settings: prautoblogger_board_poll_interval (default 5s), prautoblogger_board_published_window_days (default 7)
- PHPUnit: BoardDataProviderTest (8 tests) + CostReporterSqlBugFixTest (3 tests)
- Docs: ARCHITECTURE.md §22 + options table + file tree; CHANGELOG v0.19.0; app-map updated

Agent-Session: phase2-admin-m1-engineer
This commit is contained in:
peptiderepo
2026-06-12 13:41:35 +07:00
parent 87020736ef
commit 5997dae4eb
14 changed files with 1618 additions and 639 deletions

View File

@@ -119,17 +119,16 @@ prautoblogger/
│ │ └── OFL.txt # SIL Open Font License
│ ├── css/
│ │ ├── admin.css # Admin page styles (wp-admin conventions)
│ │ ├── board.css # Kanban board styles — warm editorial palette (v0.19.0)
│ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars)
│ └── js/
│ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js)
│ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0)
│ └── posts-widget.js # React component for frontend post cards (wp.element)
│
├── includes/
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution
│ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages)
│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
│ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry
│ ├── class-generation-status-poller.php # Generation AJAX handlers: status transient renewal, lock-age orphan detection (R2b/R3), abort_orphaned_run
│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry
│ ├── class-ajax-handlers.php # Non-generation AJAX: images, models, test connections
│ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation
│ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron
@@ -137,6 +136,8 @@ prautoblogger/
│ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes
│ │
│ ├── admin/
│ │ ├── class-board-page.php # Kanban board — primary landing screen (v0.19.0)
│ │ ├── class-board-data-provider.php # Board data: 4-column card snapshots from gen_log + post meta (v0.19.0)
│ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI)
│ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources)
│ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images
@@ -174,19 +175,6 @@ prautoblogger/
│ │ ├── class-image-attacher.php # Sideload + cost log + variant meta-linking + caption prepend
│ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library
│ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits
│ │ ├── class-stage-display-map.php # Stage vocabulary: labels + default roles + prompt keys (old + new + image stages)
│ │ ├── class-prompt-registry.php # Versioned prompt registry, read side (render, pins, self-healing fallback)
│ │ ├── class-prompt-registry-writer.php # Registry write side (create version, activate, seed) — versions immutable
│ │ ├── class-prompt-defaults.php # Canonical v1 bodies: content.* + analysis.* (seed + fallback single source)
│ │ ├── class-prompt-defaults-editorial.php # Canonical v1 bodies: editor.* / research.system / image.* (composer seam)
│ │ ├── class-run-context.php # Per-process active run id (set by Cost_Tracker::set_run_id)
│ │ ├── class-run-state.php # runs-table ledger + lifecycle row (ceiling/reserved/settled, pins, status)
│ │ ├── class-cost-governor.php # Per-run reserve-before-call enforcement (atomic conditional UPDATE)
│ │ ├── class-cost-ceiling-exception.php # Thrown on ceiling breach (run already halted)
│ │ ├── class-run-stage-state.php # Per-run per-stage state machine (idempotent resume, output snapshots)
│ │ ├── class-run-reaper.php # Stuck-run sweep + audit-payload retention (rides the #19 cron)
│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer
│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker)
│ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug)
│ │ ├── class-article-worker.php # Single-article generation (content + edit + publish)
│ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs
@@ -196,7 +184,6 @@ prautoblogger/
│ ├── providers/
│ │ ├── interface-llm-provider.php # Contract for any LLM provider
│ │ ├── class-open-router-provider.php # OpenRouter API implementation (chat completions)
│ │ ├── class-open-router-completion-guard.php # Empty-completion guard: warn on finish_reason=length, one reasoning-off retry, audited failure (v0.18.1)
│ │ ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup
│ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation
│ │ ├── interface-source-provider.php # Contract for any social media source
@@ -209,13 +196,11 @@ prautoblogger/
│ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers
│ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation
│ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway)
│ │ ├── class-open-router-request-builder.php # Request body assembly + reasoning token cap/headroom (v0.18.1) + headers + Hostinger cURL auth workaround
│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround
│ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+)
│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver
│ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap
│ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher
│ │ ├── class-runware-model-catalog.php # Live model catalog sync: modelSearch pagination, cache v2, failure cooldown
│ │ ├── class-runware-catalog-fetcher.php # HTTP fetch + normalize_models for Runware modelSearch (extracted for 300-line rule)
│ │ └── (new providers go here — see CONVENTIONS.md)
│ │
│ ├── services/
@@ -239,6 +224,7 @@ prautoblogger/
│
├── templates/
│ └── admin/
│ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0)
│ ├── settings-page.php # Settings page template (tabbed sidebar layout)
│ ├── metrics-page.php # Metrics/cost dashboard template
│ ├── review-queue.php # Review queue table template
@@ -340,19 +326,11 @@ Hostinger's 120-second LiteSpeed connection timeout. The frontend polls for stat
└──────────────────────────────┘
```
**Fallback detection (v0.18.3 R2b/R3):** The status transient is renewed on every stage
transition via `update_generation_stage()` (`Pipeline_Status::broadcast()`), so its TTL
always counts from the last activity rather than run start. When a status poll finds the
transient absent:
- **Lock within TTL (`lock_age ≤ STATUS_TTL = 600s`):** the background process is still alive
but has not broadcast yet (or the transient was evicted). `handle_missing_transient()` returns
`status: running` (R3) — button stays in-progress, no state change.
- **Lock exceeded TTL (`lock_age > STATUS_TTL`):** background process died without releasing the
lock. `abort_orphaned_run()` marks the `runs` row `failed`, releases the generation lock, deletes
the status transient and queue option, and returns `status: error` with "infrastructure timeout"
(R2b). A daily `Run_Reaper::sweep_stuck_runs()` at 2× `EXPECTED_RUN_SECONDS` (~3600s) remains
the backstop for runs the poll path never sees.
**Fallback detection:** If Hostinger kills the PHP process before it updates the
transient (despite `ignore_user_abort`), the status endpoint detects the orphan after
180 seconds by checking for recently-created posts with `_prautoblogger_run_id` meta.
It also releases any stale generation lock. After 600 seconds (STATUS_TTL), it gives
up unconditionally and reports a timeout.
---
@@ -406,7 +384,7 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
| id | BIGINT UNSIGNED | Auto-increment PK |
| post_id | BIGINT UNSIGNED | WordPress post ID (nullable, set after publish)|
| run_id | VARCHAR(36) | UUID linking all entries from one pipeline run |
| stage | VARCHAR(50) | See "Stage vocabulary" below. Canonical edit-stage name is **'polish'** (this doc previously said 'edit'; the code always wrote 'polish') |
| stage | VARCHAR(50) | 'analysis', 'outline', 'draft', 'edit', 'review' |
| provider | VARCHAR(50) | 'openrouter' |
| model | VARCHAR(100) | Model identifier used |
| prompt_tokens | INT | Input tokens consumed |
@@ -415,8 +393,6 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
| request_json | LONGTEXT | Full request payload (for debugging) |
| response_status | VARCHAR(20) | 'success', 'error', 'timeout' |
| error_message | TEXT | Error details if failed (nullable) |
| agent_role | VARCHAR(50) | v0.18.0, nullable. Role that made the call ('writer', 'editor', 'analyst', 'researcher', 'illustrator', …). Historical rows stay NULL |
| prompt_version | VARCHAR(20) | v0.18.0, nullable. Pinned prompt-registry version the call rendered with. Historical rows stay NULL |
| created_at | DATETIME | When the API call was made |
- INDEX: `post_id`
@@ -424,18 +400,6 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix.
- INDEX: `created_at`
- INDEX: `stage`
**Stage vocabulary.** `stage` is a VARCHAR, not a SQL enum; the vocabulary lives in
`PRAutoBlogger_Stage_Display_Map` (PHP), which renders historical and new values coherently
and maps each stage to its default agent role + primary prompt-registry key:
- **Historical values (exist on prod):** `analysis`, `outline`, `draft`, `polish`, `review`,
`llm_research`, `image_a`, `image_b`, `image_prompt_rewrite`, `opik_eval_judge`.
Note: single-pass generation logs its one call as `draft` (no `outline` row — that absence
is how single-pass and multi-step runs are told apart in cost reporting).
- **Pipeline v2 values (new runs may write):** `research`, `curate`, `draft`, `editorial`,
`seo`, `publish`.
- Unknown values render via a humanizing fallback — the audit view never renders blank.
#### `prab_event_log` — Structured application logging
| Column | Type | Description |
@@ -468,97 +432,6 @@ and maps each stage to its default agent role + primary prompt-registry key:
- INDEX: `measured_at`
- INDEX: `composite_score`
#### Pipeline v2 substrate tables (v0.18.0, db 1.2.0)
Real table names use the standard prefix, e.g. `wp_prautoblogger_prompts` (the plan-of-record's
`prab_*` names are shorthand). Created by `PRAutoBlogger_Pipeline_Schema_Installer`; all are
dropped on uninstall.
##### `prautoblogger_prompts` — versioned prompt registry
| Column | Type | Description |
|-------------|-----------------|----------------------------------------------------------|
| id | BIGINT UNSIGNED | Auto-increment PK |
| prompt_key | VARCHAR(64) | Registry key, e.g. 'content.single_pass' (`key` is reserved SQL) |
| version | INT UNSIGNED | Monotonic per key; **rows are immutable** — edits create new versions |
| body | LONGTEXT | Prompt template; `{{ token }}` placeholders filled at render time |
| model | VARCHAR(100) | Optional model hint (informational in Phase 1) |
| params_json | LONGTEXT | Optional params snapshot (temperature, max_tokens, …) |
| author | VARCHAR(100) | Who created the version ('seed:v0.18.0', wp user login, agent) |
| created_at | DATETIME | Version creation time |
| active | TINYINT(1) | Exactly one active row per key |
- UNIQUE: `prompt_key, version` · INDEX: `prompt_key, active`
##### `prautoblogger_run_sources` — per-run source audit
| Column | Type | Description |
|---------------|--------------|----------------------------------------------|
| id | BIGINT UNSIGNED | Auto-increment PK |
| run_id | VARCHAR(36) | Pipeline run UUID |
| agent_role | VARCHAR(50) | Role that surfaced the source |
| source_url | VARCHAR(500) | Source URL (nullable) |
| doi | VARCHAR(255) | DOI when known (nullable) |
| kept | TINYINT(1) | Keep/discard decision |
| reason | TEXT | Why kept or discarded (nullable) |
| quality_score | FLOAT | Source quality weighting (nullable) |
| created_at | DATETIME | Row creation time |
- INDEX: `run_id` — consolidates the old `source_ids_json` + `_prautoblogger_research_sources`
scatter for **new** runs; historical runs are not backfilled.
##### `prautoblogger_run_decisions` — per-stage decision audit
| Column | Type | Description |
|----------------|-------------|-----------------------------------------------|
| id | BIGINT UNSIGNED | Auto-increment PK |
| run_id | VARCHAR(36) | Pipeline run UUID |
| stage | VARCHAR(50) | Stage that decided (see stage vocabulary) |
| verdict | VARCHAR(50) | e.g. 'approved', 'revised', 'rejected', 'halted' |
| rationale | TEXT | Decision rationale (nullable) |
| citation_score | FLOAT | Nullable until the Phase-2 editorial loop computes it |
| created_at | DATETIME | Row creation time |
- INDEX: `run_id`
##### `prautoblogger_runs` — run ledger + lifecycle
| Column | Type | Description |
|---------------------|---------------|---------------------------------------------|
| run_id | VARCHAR(36) | PK — pipeline run UUID |
| status | VARCHAR(20) | pending / running / done / failed / halted |
| ceiling_usd | DECIMAL(10,6) | Per-run cost ceiling snapshotted at run start |
| reserved_usd | DECIMAL(10,6) | Outstanding reserve-before-call holds |
| settled_usd | DECIMAL(10,6) | Actual settled spend |
| overage_usd | DECIMAL(10,6) | Amount the breaching reservation exceeded the ceiling by |
| pinned_prompts_json | LONGTEXT | Map of prompt_key → version frozen at run start |
| started_at | DATETIME | Run start |
| updated_at | DATETIME | Last ledger/state write |
| finished_at | DATETIME | Set on done/failed/halted (nullable) |
- INDEX: `status` — the Cost_Governor's reserve is a single conditional UPDATE against this
row (same atomic discipline as the #10 generation lock), so concurrent `curl_multi`
writers cannot slip past the ceiling between check and call.
##### `prautoblogger_run_stages` — per-run per-stage state machine
| Column | Type | Description |
|-------------|-----------------|----------------------------------------------------|
| id | BIGINT UNSIGNED | Auto-increment PK |
| run_id | VARCHAR(36) | Pipeline run UUID |
| stage | VARCHAR(50) | Stage name (see stage vocabulary) |
| agent_role | VARCHAR(50) | Fan-out dimension. Populated in Phase 1 from `Stage_Display_Map::default_agent_role()` (e.g. 'writer', 'editor', 'publisher'). Phase-2 quorum passes multiple roles for the same stage. |
| item_key | VARCHAR(64) | Scopes article-level stages within multi-article runs ('' for run-level) |
| status | VARCHAR(20) | pending / running / done / failed / halted |
| attempt | SMALLINT UNSIGNED | Re-entry counter |
| cost_usd | DECIMAL(10,6) | Cost attributed to the stage |
| meta_json | LONGTEXT | Stage output snapshot — lets resume reuse a done stage instead of re-charging it (payload pruned on the retention cron) |
| started_at | DATETIME | First entry |
| updated_at | DATETIME | Last transition |
| finished_at | DATETIME | Set on done/failed (nullable) |
- UNIQUE: `run_id, stage, agent_role, item_key` (the idempotency key) · INDEX: `status, updated_at`
### WordPress Options (`wp_options`)
All prefixed with `prautoblogger_`:
@@ -573,14 +446,11 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_analysis_model` | OpenRouter model for analysis (default: cheap) |
| `prautoblogger_writing_model` | OpenRouter model for writing (default: quality) |
| `prautoblogger_editor_model` | OpenRouter model for chief editor (default: quality) |
| `prautoblogger_reasoning_max_tokens` | v0.18.1 — hard cap on thinking tokens per call when reasoning is enabled; sent as OpenRouter `reasoning.max_tokens` (replaces `effort` when active) and the request `max_tokens` is raised by the cap so reasoning can never consume the visible-content budget; 0 = legacy uncapped effort mode. Default `PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` (2048) |
| `prautoblogger_daily_article_target` | Number of articles per day (1-10, default: 1) |
| `prautoblogger_writing_pipeline` | 'single_pass' or 'multi_step' (default: multi_step) |
| `prautoblogger_niche_description` | Text description of the site's niche |
| `prautoblogger_target_subreddits` | JSON array of subreddits to monitor |
| `prautoblogger_monthly_budget_usd` | Monthly API spend limit in USD |
| `prautoblogger_per_run_cost_ceiling_usd` | v0.18.0 — per-run hard cost ceiling (USD); reserve-before-call enforced; snapshotted onto the runs row at run start; 0 = disabled. Default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` ($0.50) |
| `prautoblogger_request_json_retention_days` | v0.18.0 — days before heavy audit payloads (generation_log.request_json, run_stages.meta_json) are NULLed by the daily cleanup cron; 0 = keep forever. Default `PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` (14) |
| `prautoblogger_tone` | Content tone (informational, conversational, etc.) |
| `prautoblogger_min_word_count` | Minimum article word count (default: 800) |
| `prautoblogger_max_word_count` | Maximum article word count (default: 2000) |
@@ -608,6 +478,8 @@ All prefixed with `prautoblogger_`:
| `prautoblogger_image_compose_variants` | Comma list of composed variants for Image A (default 'og,square'; whitelisted at point of use) |
| `prautoblogger_image_featured_mark_enabled` | Toggle: subtle corner mark on featured images (default '1') |
| `prautoblogger_image_compose_capability` | Cached capability probe `{fingerprint, capability}`; fingerprint = PHP version + imagick/gd presence, so it auto-invalidates on host changes |
| `prautoblogger_board_poll_interval` | Kanban board poll interval in seconds (default 5, min 3). Localized into board.js. |
| `prautoblogger_board_published_window_days` | Days back to show in the Published column (default 7). |
### Post Meta
@@ -626,7 +498,6 @@ Stored on every PRAutoBlogger-generated post:
| `_prautoblogger_editor_notes` | Chief editor's review notes |
| `_prautoblogger_generated_at` | ISO 8601 timestamp of generation |
| `_prautoblogger_research_sources` | JSON array of source URLs used |
| `_prautoblogger_idea_hash` | v0.18.0 — stable hash of the idea (title|topic); with `_prautoblogger_run_id` forms the post-creation idempotency key |
| `_prautoblogger_og_image_id` | Attachment ID of the composed 1200×630 OG variant (v0.17.0; the rebuilt SEO stage emits `og:image` from this — key name frozen) |
| `_prautoblogger_square_image_id` | Attachment ID of the composed 1080×1080 square variant (v0.17.0; stored now, no consumer yet) |
@@ -805,61 +676,15 @@ We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway
---
### #22: Pipeline v2 Phase 1 substrate (v0.18.0, db 1.2.0, June 2026)
Phase 1 of the CEO-approved pipeline rebuild (plan of record:
`convo/cpo/threads/2026-06-prautoblogger-pipeline-redesign/03-cto-revised-brief.md`), built
**on the current pipeline with no behavior change** to the Economy (single-pass) and
multi-step publish paths. Four subsystems:
1. **Versioned prompt registry** (`prautoblogger_prompts`). All pipeline prompt copy
(content/analysis/editor/research + the illustration rewriter prompt and the image Style
Template) lives as immutable versioned rows; the hardcoded v0.16.0 texts are seeded as v1
and remain the in-code fallback, so a missing/empty table can never fatal — call sites
render through `PRAutoBlogger_Prompt_Registry` and fall back to the same constants the
seed used (byte-identical output). A run pins the active version of every key at start
(`runs.pinned_prompts_json`) and every generation_log row is stamped with the pinned
`prompt_version`. The image-composer PR **consumes** the `image.*` keys — it must not
grow its own prompt storage. No admin UI in Phase 1; the API supports list/activate/
create-version (never mutate) for the Phase-2 Prompts screen.
2. **Additive audit schema.** `agent_role` + `prompt_version` columns on generation_log;
`run_sources` + `run_decisions` child tables; `PRAutoBlogger_Stage_Display_Map` renders
historical + image + v2 stage values. `request_json` (and stage output payloads in
`run_stages.meta_json`) are nulled after R days — R from the
`prautoblogger_request_json_retention_days` setting (default 14), pruned on the existing
daily reaper cron.
3. **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`). Reserve-before-call against the
per-run ledger row using a single conditional UPDATE (the #10 atomic-write discipline);
estimates come from the #18 pricing chain; `curl_multi` image batches reserve their summed
estimate before dispatch; reservations settle to actuals after each response. Breach →
run `halted`, overage recorded, un-dispatched work aborted, surfaced on the Review Queue.
Ceiling = `prautoblogger_per_run_cost_ceiling_usd` setting (default $0.50, 0 = disabled).
The monthly `Cost_Tracker` cap and the Cloudflare AI Gateway (#15) path are unchanged.
4. **Run state machine + idempotency** (`PRAutoBlogger_Run_State`). Per-run per-stage state
(pending/running/done/failed/halted) keyed `run_id + stage + agent_role + item_key`
(item_key scopes article-level stages because one run_id spans all N articles of a batch);
done stages persist output and are reused on re-entry, never re-charged; post creation is
keyed by `_prautoblogger_run_id` + `_prautoblogger_idea_hash` (check-before-insert) so a
retried run cannot duplicate a post. `PRAutoBlogger_Run_Reaper` (same daily cron as #19)
marks runs stuck `running` > 2× expected stage wall-clock as `failed`; such runs render
as **"incomplete"** in audit queries. Quorum logic (multiple roles per stage) itself is Phase 2. In Phase 1,
`agent_role` is populated on every row from `Stage_Display_Map::default_agent_role()`
so the run timeline is self-describing and the idempotency key is fully populated.
### #20: PHPUnit test infrastructure + WordPress-Core PHPCS compliance (v0.10.1)
Tests use Brain\Monkey for WordPress function mocking (no database required). The BaseTestCase singleton handles setup/teardown of Monkey stubs and provides fixture helpers for common data shapes (SourceData, ArticleIdea, GenerationLog, etc.). All WordPress functions used by the plugin are stubbed in BaseTestCase::setUp() so individual test classes only override what they need. In v0.10.1, missing stubs for `post_type_exists()` were added to support PeptideLinker's PR-Core guards. All 1,362 WordPress-Core coding-standards violations (short array syntax, line length, spacing) were auto-fixed via phpcbf and the CI gate was flipped to strict mode — PHPCS failures now block CI. This prevents future regressions on the code style front.
## Cross-System LLM Budget Coordination
### #22: Kanban board as primary admin landing screen (v0.19.0)
PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets.
| Plugin | Default Models | Typical Daily Spend | Budget Control |
|--------|---------------|--------------------:|----------------|
| PRAutoBlogger | Gemini 2.5 Flash Lite (analysis + editing), Claude Sonnet 4 (writing) | $0.05–$0.30 depending on article count | Hard-stop monthly budget in plugin settings |
| Peptide News | Google Gemini 2.0 Flash (keywords + summaries) | $0.01–$0.05 | No hard budget yet (planned) |
**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.
The primary admin screen is now a kanban board (Direction C "Editorial Record") with four
columns: Generating | In Review | Published | Failed. A separate `class-board-page.php`
registers the `Board` submenu first so it appears before Settings in the nav.
`class-board-data-provider.php` computes column cards from existing tables
(`prab_generat

View File

@@ -5,247 +5,40 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.18.3] - 2026-06-12
GA4-context SQL fix, manual-run resilience, and honest status on transient expiry
(CTO thread `2026-06-pipeline-v2-phase1` seq 38).
### Fixed
- **Analysis-prompts SQL / GA4 self-improvement loop (R1 — P2)**: `phpcs:ignore` annotation
was embedded inside the SQL string literal in `class-analysis-prompts.php:81` (same pattern
as the v0.18.2 cost-reporter fix). MariaDB rejected the query on every pipeline run,
causing `get_performance_context()` to always return `''` — the LLM research/analysis
stage has had no historical performance context since v0.16.0. Annotation moved to the
`// phpcs:ignore` pragma above `get_results()`. Sibling occurrence also fixed in
`class-post-assembler.php:112` (same pattern). Regression + behavioral tests added to
`AnalysisPromptsTest`.
- **Manual-run resilience — transient renewal (R2a)**: `update_generation_stage()` in the
new `PRAutoBlogger_Generation_Status_Poller` now renews the STATUS_TTL on every stage
update, ensuring the status transient outlasts any single LLM call even when `Pipeline_
Status::broadcast()` is delayed by a long model call.
- **Manual-run resilience — stuck-run detection (R2b)**: `on_ajax_generation_status()`
now detects when the status transient is absent but the generation lock has been held
longer than STATUS_TTL seconds — indicative of a Hostinger background-process kill.
Marks the stuck run `failed` in the audit ledger, releases the lock, and returns
`status: error` with an "infrastructure timeout" message instead of silently resetting
to idle.
- **Honest status when transient gone but lock held (R3)**: when the status transient is
absent but the lock is held within TTL, `on_ajax_generation_status()` now returns
`status: running` with `started_at` from the lock timestamp (rather than `status: idle`)
so the Generate Now button reflects reality during long background runs.
### Changed
- **Executor split (300-line rule)**: `class-executor.php` was at 351 lines. AJAX generation
handlers (`on_ajax_generate_now`, `on_ajax_generation_status`, `abort_orphaned_run`,
`update_generation_stage`) extracted to new `class-generation-status-poller.php` (260 lines).
Executor retains cron handlers and delegates AJAX calls to the poller. Public API unchanged.
- **Generation_Lock**: new `get_acquired_at()` method returns the Unix timestamp when the
lock was set, used by R2b/R3 to determine lock age without touching the pipeline run table.
## [0.19.0] - 2026-06-12
### Added
- **Runware catalog `modelSearch` migration**: `class-runware-model-catalog.php` migrated
from the retired `taskType:models` endpoint to the current `taskType:modelSearch` API.
Payload format: `[{taskType:authentication,apiKey:...},{taskType:modelSearch,
taskUUID:<uuid4>,offset:N,limit:N}]`. Response normalization updated for the new shape:
`air` identifier replaces `id`, `capabilities[]` array replaces `taskType`/`requiresImage`
(filter: entry must contain `"io:text-to-image"`), `comment` field maps to `description`.
Logic extracted to `class-runware-catalog-fetcher.php` companion (300-line rule). Cache
option key bumped to `prautoblogger_runware_model_cache_v2` to cleanly invalidate v1 rows.
- **Pagination for `modelSearch`**: fetcher paginates via `offset`/`limit` up to 5 pages of
100 results = 500 models inspected. Runware's catalog has 320k+ entries (mostly community
checkpoints); 5 pages reliably captures all curated text-to-image models. A page-cap
warning is logged if the cap is reached before exhausting `totalResults`. Client-side
`io:text-to-image` capability filter applied — no server-side filter is available.
- **Negative-result failure cooldown**: `sync()` records a failure timestamp in
`prautoblogger_runware_catalog_last_failure_at` on every error path. `get_models()`
gates re-trigger through `is_in_failure_cooldown()`, which respects a configurable
window from `prautoblogger_runware_catalog_failure_cooldown_seconds` (default 3600s / 1h,
never hardcoded at point of use). A successful sync clears the timestamp. This caps the
admin-page-load cascade (42 errors/day observed) to ~24/day even when the cache is absent.
- **Hardcoded fallback list preserved**: unchanged; carried prod through the entire
`taskType:models` outage. Tests verify it is always non-empty and correctly priced.
## [0.18.2] - 2026-06-12
Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25).
- **Kanban board dashboard (M1 — Phase 2 admin / Direction C).** New
`Board` submenu page is now the primary landing screen for the plugin.
Four columns — Generating | In Review | Published | Failed — with card
click-throughs: Generating/Failed → Activity Log; In Review → Review
Queue; Published → Post edit screen. (M2 will rewire all to the Article
Dossier.)
- **Live board polling.** AJAX action `prautoblogger_board_status` (nonce
`prautoblogger_board`) returns the full board snapshot every N seconds.
Poll interval backs off to 2× when no article is actively generating
and resets to base as soon as a generating card appears. All nonce and
capability checks enforced.
- **Two new settings** in Schedule & Budget: `prautoblogger_board_poll_interval`
(default 5s, min 3s) and `prautoblogger_board_published_window_days`
(default 7 days). Both localized into board.js — no hardcoded values.
- New files: `includes/admin/class-board-page.php`,
`includes/admin/class-board-data-provider.php`,
`templates/admin/board-page.php`, `assets/css/board.css`,
`assets/js/board.js`.
- PHPUnit behavioral tests: `tests/unit/Admin/BoardDataProviderTest.php`
(8 tests covering all column/state mappings) and
`tests/unit/Core/CostReporterSqlBugFixTest.php` (3 tests for the SQL
inline-comment fix).
### Fixed
- **Cost-reporter SQL (P2)**: `phpcs:ignore` annotations were embedded inside two SQL string
literals in `class-cost-reporter.php` (`get_daily_spend` and `get_spend_by_stage`). MariaDB
received the comment text as literal SQL and rejected both queries, breaking the Metrics &
Costs admin page. Annotations moved above the `$wpdb->prepare()` calls. Regression tests
added to `CostReporterTest` asserting no `// phpcs:ignore` text appears in the SQL passed
to `prepare()`.
- **Collector failure observability (P2)**: `RuntimeException` catch in
`Source_Collector::collect_from_all_sources()` now logs structured meta — model slug,
HTTP status parsed from the exception message, and the first 200 chars of the error body —
so a 27-day outage like the seq-22 llm_research 404 incident is diagnosable after the fact.
No behaviour change; logging only.
- **`run_stages.agent_role` symmetric write (P3)**: `Pipeline_Runner`, `Article_Worker`, and
`Content_Generator` were calling `start()` with the mapped role but leaving `done()`,
`is_done()`, and `get_output()` with `agent_role = ''`. With `agent_role` in the UNIQUE key,
the mismatch addressed different rows — stranding `start()` rows in `running` forever and
breaking idempotent resume (duplicate-post vector). Fixed via an in-class seam in
`Run_Stage_State::resolve_role()`: when the caller passes `''`, the role is derived from
`Stage_Display_Map::default_agent_role($stage)` before any DB query, ensuring every method
addresses the same row. Explicit non-empty roles (Phase-2 fan-out) are passed through
unchanged. `get()` falls back to `role=''` on miss for mid-run-upgrade compat with v0.18.1
rows. Two new `RunStageStateTest` cases lock the symmetric-role and fallback contracts.
ARCHITECTURE.md §22 updated to document Phase 1 role population.
- **Runware float deprecation (P3)**: `normalize_seed()` in `class-runware-image-support.php`
applied the modulo operator to a float (`microtime(true) * 1000`), triggering
`Deprecated: Implicit conversion from float … to int` on every image generation. Fixed
with `(int) round(...)` before the `%` operator.
## [0.18.1] - 2026-06-11
Empty-draft hotfix (CTO diagnosis: convo thread `2026-06-pipeline-v2-phase1` seq 12; prod run
`acf24029`, draft post 921). A true reasoning model at a high reasoning effort could spend the
entire `max_tokens` completion budget on thinking, return zero visible content with
`finish_reason=length`, and the empty article flowed writer → editor (reject: "draft text is
missing") → empty draft post in the Review Queue, with the cost booked as `success`.
### Fixed
- **Empty-completion guard at the call seam** (`PRAutoBlogger_OpenRouter_Completion_Guard`):
a chat completion whose visible content is empty/whitespace is now a **failure**, never a
success. When the empty result co-occurs with `finish_reason=length` or an active reasoning
request, the call is retried **once** with reasoning disabled (per-call `reasoning`
override) before the stage fails. Failed/retried attempts land in `generation_log` with
`response_status='error'`, the failure note, and the real token burn; inside a v0.18.0 run a
`run_decisions` row records the verdict (`retry_reasoning_disabled` /
`failed_empty_completion`).
- **Writer-path guard**: a writing stage (outline/draft/polish/single-pass) whose output is
empty throws instead of passing an empty article to the editor (belt over the seam guard,
covers any future non-OpenRouter provider).
- **Publisher belt-and-braces**: `Publisher::create_post()` refuses to create any post —
publish or draft — whose content is empty after tag stripping. No empty draft post can ever
be created again.
- `finish_reason=length` is now always warning-logged with model, stage, and
prompt/completion/reasoning token counts (previously silent — the truncated `llm_research`
call in the incident run was invisible).
### Added
- **Reasoning token cap + completion headroom**: when a request enables reasoning, the
thinking budget is capped via OpenRouter's `reasoning.max_tokens` and the request's
`max_tokens` is **raised** by the same amount, so no reasoning effort (incl. `xhigh`) can
consume the visible-content budget. New setting `prautoblogger_reasoning_max_tokens`
(Settings → AI Models, default `PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` = 2048; 0
restores the previous uncapped effort mode; the option is sanitized server-side as
numeric). When the cap is active it replaces `effort`
(OpenRouter treats them as alternative budget controls); the `xhigh`/`high` effort options
stay available but are bounded by default. Cost-governor reservations use the effective
(raised) ceiling.
- LLM call sites now pass their pipeline stage to the provider (`stage` option, never sent
upstream) so guard warnings and failure rows are attributable: `analysis`, `review`,
`llm_research`, `image_prompt_rewrite`, `opik_eval_judge`, and the writer stages (which
also pass `prompt_key`).
### Notes
- `OpenRouter_Provider::send_chat_completion()` body assembly moved to
`OpenRouter_Request_Builder::build_body()` (300-line cap; behavior unchanged apart from the
reasoning budget above).
## [0.18.0] - 2026-06-12
Pipeline v2 — Phase 1 substrate (plan of record: CPO thread
`2026-06-prautoblogger-pipeline-redesign`, seq 3). Built on the current pipeline with **no
behavior change** to the Economy (single-pass) and multi-step publish paths.
### Added
- **Schema v1.2.0** (`prautoblogger_db_version` 1.1.0 → 1.2.0, idempotent dbDelta
migrations, self-healing on version mismatch): new tables
`wp_prautoblogger_prompts` (versioned prompt registry),
`wp_prautoblogger_run_sources` + `wp_prautoblogger_run_decisions` (audit child tables),
`wp_prautoblogger_runs` (per-run cost ledger + lifecycle) and
`wp_prautoblogger_run_stages` (per-run per-stage state machine, idempotency key
`run_id + stage + agent_role + item_key`); additive nullable columns `agent_role` and
`prompt_version` on `wp_prautoblogger_generation_log` (historical rows stay valid).
- `PRAutoBlogger_Stage_Display_Map`: single PHP vocabulary for historical stages
(`analysis`, `outline`, `draft`, `polish`, `review`, `llm_research`, `image_a`, `image_b`,
`image_prompt_rewrite`, `opik_eval_judge`), Pipeline v2 stages (`research`, `curate`,
`draft`, `editorial`, `seo`, `publish`) and a humanizing fallback for unknown values, plus
per-stage default agent role and primary prompt-registry key.
- Uninstall now drops the five substrate tables and clears every plugin cron hook
(previously only two of eight were cleared).
- **Versioned prompt registry** (`PRAutoBlogger_Prompt_Registry` + `_Writer`): all pipeline
prompt copy — content system/single-pass/outline/draft/polish, analysis system/user,
editor system/review, research system, plus the **illustration rewriter prompt and the
image Style Template** (the image-composer PR seam) — is seeded as immutable version 1
from the v0.16.0 hardcoded texts (`prautoblogger_migrated_prompt_seed_v0180`). One active
version per key; changes create new versions, never mutate. Call sites render through the
registry with a **byte-identical in-code fallback** when the table is missing/empty
(self-healing, no fatals on half-migrated state). No admin UI in Phase 1.
- **Prompt-version pinning**: `Cost_Tracker::set_run_id()` pins the active version of every
key on the run's row; every generation_log row (text and image stages) is stamped with the
pinned `prompt_version` plus a stage-derived `agent_role`. Single-pass rows keep stage
`draft` and stamp `content.single_pass` explicitly.
- `PRAutoBlogger_Run_State` (runs-table ledger row: ceiling/reserved/settled/overage, pins,
sticky lifecycle states) and `PRAutoBlogger_Run_Context` (per-process run id, mirrors the
Opik trace-context pattern).
- **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`, net-new enforcement — previously
only the monthly cap was enforced): every `send_chat_completion()` reserves its worst-case
estimate (prompt chars/4 + max_tokens, priced via the #18 chain) against the run ledger
with a single atomic conditional UPDATE (the #10 lock discipline) BEFORE dispatch; the
image `curl_multi` batch reserves its summed estimate before dispatch; reservations settle
to actuals after each response (a failed call releases its hold). Breach → run `halted`
(sticky), overage recorded, `Cost_Ceiling_Exception` aborts the call, remaining queued
articles abort, and the run is surfaced in a "Halted runs" notice on the Review Queue.
New setting **Per-Run Cost Ceiling (USD)** (`prautoblogger_per_run_cost_ceiling_usd`,
default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` = $0.50, snapshotted at run start,
0 = disabled). Calls outside a run, the monthly budget, and the Cloudflare AI Gateway
path behave exactly as before.
- **Run state machine + idempotent resume** (`PRAutoBlogger_Run_Stage_State`): per-run
per-stage rows keyed `run_id + stage + agent_role + item_key` (item_key scopes
article-level stages — one run_id spans all N articles of a batch; agent_role is the
Phase-2 quorum dimension, schema-only for now). Done stages snapshot their output and are
REUSED on re-entry — never re-run, never re-charged (writer stages via
`Content_Generator::set_run_item()`, the editorial review via its stored verdict, a fully
published item is skipped outright). **Post creation is keyed by `_prautoblogger_run_id`
+ new `_prautoblogger_idea_hash` meta (check-before-insert)** so a retried run cannot
create a duplicate post. Run-level research/analysis checkpoints ride Pipeline_Runner;
completion/failure marks land on the runs row (Executor catch paths, orphan aborts,
monthly-budget aborts).
- **Reaper extension** (`PRAutoBlogger_Run_Reaper`, same daily cron as the #19 research
reaper — no new schedule): stages stuck `running` > 2× expected stage wall-clock and runs
silent > 2× expected run wall-clock are marked `failed` (filterable expectations); their
open stages are reaped; such runs are queryable as **"incomplete"** via
`Run_Reaper::incomplete_runs()`. Also enforces **retention**: `request_json` on the
generation log and stage output snapshots are NULLed after R days — R from the new
**Audit Payload Retention** setting (`prautoblogger_request_json_retention_days`, default
`PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` = 14, 0 = keep forever).
- **Audit writes** (`PRAutoBlogger_Audit_Writer`): every reviewed article records its
editorial verdict in `run_decisions`; every published article records the sources that
fed it in `run_sources` (the source_ids_json / `_prautoblogger_research_sources`
consolidation for new runs; no backfill).
### Changed
- `Content_Generator`'s four stage methods now share one `execute_stage()` helper (the Opik
span + dispatch + cost-log boilerplate was identical); file drops from 301 to ~220 lines.
No behavior change: same prompts, models, params, span names, stage values.
- `Cost_Tracker::get_avg_tokens_for_stages()` body moved to `Cost_Reporter` (read-only
reporting query); the Cost_Tracker method remains as a delegating wrapper.
- `OpenRouter_Provider`'s API-key fetch + format validation moved to
`OpenRouter_Request_Builder::resolve_api_key()` (300-line cap); identical messages/behavior.
- Pipeline_Runner's and Article_Worker's duplicated status-transient/summary helpers moved
verbatim to the new `PRAutoBlogger_Pipeline_Status` (Pipeline_Runner back under the
300-line cap at 271).
### Fixed
- ARCHITECTURE.md documented the multi-step edit stage as `edit`; the code has always
written `polish`. Canonicalized to **`polish`** everywhere.
- Half-migrated-schema hardening: generation_log inserts retry without the new audit
columns when the columns are missing (a cron run right after deploy no longer loses cost
rows), and the db-version self-heal now also runs on cron requests (`init` +
`wp_doing_cron()`), not only on `admin_init`.
- OpenRouter pricing now resolves dated snapshot model IDs returned by the API
(e.g. response model `deepseek/deepseek-v4-flash-20260423` for a
`deepseek/deepseek-v4-flash` request) to the base model's price before the
conservative-estimate fallback applies. Such responses were previously booked
at the conservative $10/$30 per-M rate (~30x real cost on flash-tier models) —
phantom spend that ate the per-run cost ceiling and the monthly budget gate.
- **Cost-reporter SQL inline-comment bug (P2).** `PRAutoBlogger_Cost_Reporter::get_daily_spend()`
and `get_spend_by_stage()` embedded PHP `// phpcs:ignore` annotations
inside SQL string literals, passing the comment text to MySQL. Moved
phpcs suppression annotations to standalone PHP line comments above the
`$wpdb->prepare()` call — matching the pattern already used in
`get_monthly_spend()`. No query behavior change; fixes malformed
prepared-statement strings visible in debug logging.
## [0.17.0] - 2026-06-11
@@ -1195,36 +988,4 @@ swap with no user-visible change in article quality.
- Configurable research cache TTL (1–72 hours) in Sources tab.
- Reddit time window selector (24h / week / month) in Sources tab.
- Posts-per-subreddit limit (5–100) in Sources tab.
- LiteSpeed cache purge step in CI/CD deploy pipeline.
### Removed
- `class-reddit-api-client.php` — replaced by `class-reddit-json-client.php`.
- Reddit OAuth credential fields and encrypted storage entry.
### Fixed
- Deploy pipeline now purges LiteSpeed/OPcache after file extraction,
preventing stale PHP bytecode from being served.
## [0.1.0] — 2026-04-10
### Added
- Initial plugin scaffold with WordPress-native architecture.
- OpenRouter LLM provider with configurable model selection and retry logic.
- Reddit API source provider (OAuth2 script app) for collecting posts and comments.
- Content analysis engine: detects recurring questions, complaints, and comparisons.
- Idea scorer with deduplication against existing posts.
- Configurable writer agent pipeline (single-pass or multi-step: outline → draft → polish).
- Chief editor agent for LLM-powered editorial review before publishing.
- Publisher with full generation metadata stored as post_meta.
- Cost tracker with per-call logging, monthly budget enforcement, and hard-stop.
- Metrics collector: WordPress native + GA4 integration + composite content scoring.
- Admin settings page with sections for API keys, models, content, sources, schedule, and analytics.
- Metrics dashboard showing monthly spend, budget utilization, cost by stage, and daily spend.
- Post metabox showing generation metadata on PRAutoBlogger-generated posts.
- Admin notices for missing API keys, budget warnings, and configuration hints.
- Stub providers for TikTok, Instagram, and YouTube (future implementation).
- Encrypted storage for API keys using AES-256-CBC with wp_salt().
- AJAX-powered "Generate Now" and "Test Connections" buttons.
- Custom database tables for source data, analysis results, generation logs, and content scores.
- Clean uninstall handler removing all plugin data.
- ARCHITECTURE.md and CONVENTIONS.md for AI-readable codebase documentation.
- LiteSpeed cache purge step in CI/CD d

249
assets/css/board.css Normal file
View File

@@ -0,0 +1,249 @@
/**
* PRAutoBlogger — Kanban Board Styles
*
* Warm editorial style (Proposal C / Direction C).
* Inside wp-admin chrome; uses WP admin vars where available.
* No build step — plain CSS, no preprocessor.
*
* Palette:
* Teal #1B8A92 (brand primary — Generating pill, spinner)
* Cream #FAF6EE (card background)
* Ink #0D0D0D (primary text)
* Orange #FF8A3D (In Review pill)
* Lime #7FD600 (Published pill — success)
* Red #D94F4F (Failed pill)
* Mid-grey #8A8A8A (meta text, border)
*
* @see templates/admin/board-page.php — Rendered HTML structure.
* @see assets/js/board.js — DOM updates that match these selectors.
*/
/* ── Page wrapper ──────────────────────────────────────────────────────── */
.prab-board-wrap {
padding: 0;
}
.prab-board-heading {
font-size: 1.4rem;
font-weight: 700;
color: #0d0d0d;
margin: 20px 0 18px 0;
padding: 0 20px;
display: flex;
align-items: baseline;
gap: 10px;
}
.prab-board-heading-sub {
font-size: 1rem;
font-weight: 400;
color: #8a8a8a;
}
/* ── Four-column board grid ────────────────────────────────────────────── */
.prab-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
padding: 0 20px 24px;
align-items: start;
}
@media (max-width: 1200px) {
.prab-board {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 720px) {
.prab-board {
grid-template-columns: 1fr;
}
}
/* ── Column ────────────────────────────────────────────────────────────── */
.prab-board-column {
background: #f8f7f5;
border: 1px solid #e2ddd6;
border-radius: 8px;
min-height: 180px;
}
.prab-col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 10px;
border-bottom: 1px solid #e2ddd6;
}
.prab-col-title {
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #4a4540;
}
.prab-col-cards {
padding: 10px 10px 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.prab-col-empty {
font-size: 0.82rem;
color: #b0a898;
text-align: center;
padding: 20px 0;
margin: 0;
}
/* ── Status pills ──────────────────────────────────────────────────────── */
.prab-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
color: #fff;
min-width: 24px;
}
.prab-pill--generating {
background: #1b8a92;
}
.prab-pill--review {
background: #ff8a3d;
}
.prab-pill--published {
background: #5a9e28;
}
.prab-pill--failed {
background: #d94f4f;
}
/* ── Card ──────────────────────────────────────────────────────────────── */
.prab-board-card {
background: #faf6ee;
border: 1px solid #e0d8cc;
border-radius: 6px;
padding: 11px 12px 10px;
position: relative;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.prab-board-card:hover {
box-shadow: 0 2px 8px rgba(27, 138, 146, 0.12);
border-color: #b8cfd2;
}
.prab-board-card--generating {
border-left: 3px solid #1b8a92;
}
.prab-card-title {
font-size: 0.875rem;
font-weight: 500;
color: #0d0d0d;
line-height: 1.4;
margin-bottom: 6px;
padding-right: 20px; /* room for spinner */
}
.prab-card-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
}
.prab-card-cost {
font-size: 0.75rem;
color: #5a8a50;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.prab-card-stage {
font-size: 0.74rem;
color: #1b8a92;
font-weight: 500;
margin-bottom: 5px;
}
.prab-card-error {
font-size: 0.74rem;
color: #d94f4f;
margin-bottom: 5px;
font-style: italic;
}
.prab-card-actions {
margin-top: 8px;
}
.prab-card-link {
display: inline-block;
font-size: 0.75rem;
color: #1b8a92;
text-decoration: none;
font-weight: 500;
border: 1px solid #b8d8db;
border-radius: 4px;
padding: 2px 8px;
transition: background 0.1s ease;
}
.prab-card-link:hover {
background: #e8f4f5;
color: #136870;
border-color: #8ac4c9;
}
/* ── Generating spinner ────────────────────────────────────────────────── */
.prab-card-spinner {
position: absolute;
top: 11px;
right: 11px;
width: 14px;
height: 14px;
border: 2px solid #c8e6e8;
border-top-color: #1b8a92;
border-radius: 50%;
animation: prab-spin 0.9s linear infinite;
}
@keyframes prab-spin {
to { transform: rotate(360deg); }
}
/* ── Board footer ──────────────────────────────────────────────────────── */
.prab-board-footer {
padding: 0 20px 16px;
font-size: 0.78rem;
color: #a0998f;
}
/* ── Poll-error banner ─────────────────────────────────────────────────── */
.prab-board-error {
background: #fff4f4;
border: 1px solid #f5c4c4;
color: #8b2020;
border-radius: 6px;
padding: 8px 14px;
margin: 0 20px 12px;
font-size: 0.82rem;
display: none;
}
.prab-board-error.is-visible {
display: block;
}

238
assets/js/board.js Normal file
View File

@@ -0,0 +1,238 @@
/**
* PRAutoBlogger — Kanban Board Poller
*
* Live-polls the board status AJAX endpoint and updates the four columns
* (Generating | In Review | Published | Failed) in-place without a full
* page reload.
*
* Poll behaviour:
* - Polls at `prabBoard.pollInterval` ms when any article is generating.
* - Doubles the interval (backoff) when `has_active_runs` is false, capping
* at 4× the base interval — prevents needless AJAX on an idle board.
* - Resets to base interval as soon as an active run is detected again.
*
* Click-throughs (M1):
* - Generating → Activity Log
* - In Review → Review Queue
* - Published → Post edit screen
* - Failed → Activity Log
* (M2 rewires all to Article Dossier)
*
* Localised config: `prabBoard` object (ajaxUrl, nonce, pollInterval, action,
* strings) injected by PRAutoBlogger_Board_Page::on_enqueue_assets().
*
* @see includes/admin/class-board-page.php — AJAX handler + localise.
* @see assets/css/board.css — Card and column styles.
* @see templates/admin/board-page.php — Initial server-rendered HTML.
*/
( function ( $ ) {
'use strict';
var cfg = window.prabBoard || {};
var baseInterval = cfg.pollInterval || 5000;
var maxBackoff = 4;
var backoffFactor = 1;
var pollTimer = null;
var $board = null;
var $errorBanner = null;
/** Escape HTML for safe DOM insertion. */
function escHtml( str ) {
return $( '<div>' ).text( String( str ) ).html();
}
/** Format cost to 4 decimal places with a leading $. */
function formatCost( cost ) {
var n = parseFloat( cost ) || 0;
return '$' + n.toFixed( 4 );
}
/**
* Build a card HTML string from a card data object.
*
* @param {Object} card Card data from server.
* @param {string} columnKey Column identifier.
* @returns {string} HTML string.
*/
function buildCardHtml( card, columnKey ) {
var isGenerating = ( columnKey === 'generating' );
var cls = 'prab-board-card' + ( isGenerating ? ' prab-board-card--generating' : '' );
var html = '';
html += '<div class="' + escHtml( cls ) + '"';
html += ' data-run-id="' + escHtml( card.run_id || '' ) + '"';
html += ' data-post-id="' + escHtml( card.post_id || 0 ) + '">';
if ( isGenerating ) {
html += '<div class="prab-card-spinner" aria-label="' + escHtml( cfg.strings.generating ) + '"></div>';
}
html += '<div class="prab-card-title">' + escHtml( card.title || cfg.strings.empty ) + '</div>';
if ( card.cost_total ) {
html += '<div class="prab-card-meta"><span class="prab-card-cost">';
html += escHtml( formatCost( card.cost_total ) );
html += '</span></div>';
}
if ( isGenerating && card.stage_current ) {
html += '<div class="prab-card-stage">';
/* translators: stage name */
html += escHtml( cfg.strings.generating + ': ' + card.stage_current );
html += '</div>';
}
if ( card.error_message ) {
var errShort = String( card.error_message ).split( ' ' ).slice( 0, 12 ).join( ' ' );
if ( card.error_message.split( ' ' ).length > 12 ) {
errShort += '…';
}
html += '<div class="prab-card-error">' + escHtml( errShort ) + '</div>';
}
// Click-through link.
var linkUrl = '';
var linkText = '';
if ( card.click_action === 'review' && card.review_url ) {
linkUrl = card.review_url;
linkText = cfg.strings.view;
} else if ( card.click_action === 'edit' && card.edit_url ) {
linkUrl = card.edit_url;
linkText = cfg.strings.edit;
} else if ( card.click_action === 'logs' && card.log_url ) {
linkUrl = card.log_url;
linkText = cfg.strings.viewLog;
}
if ( linkUrl ) {
html += '<div class="prab-card-actions">';
html += '<a href="' + escHtml( linkUrl ) + '" class="prab-card-link">';
html += escHtml( linkText );
html += '</a></div>';
}
html += '</div>';
return html;
}
/**
* Re-render a single column from the server snapshot.
*
* Updates the pill count and replaces card HTML. Avoids full DOM teardown
* to prevent flicker on the running spinner.
*
* @param {string} colKey Column identifier.
* @param {Array} cards Array of card objects.
* @returns {void}
*/
function renderColumn( colKey, cards ) {
var $col = $board.find( '[data-column="' + colKey + '"]' );
var $cards = $col.find( '[data-column-cards]' );
var $pill = $col.find( '.prab-col-count' );
$pill.text( cards.length );
if ( ! cards.length ) {
$cards.html( '<p class="prab-col-empty">' + escHtml( cfg.strings.empty ) + '</p>' );
return;
}
// Build new card HTML.
var newHtml = '';
for ( var i = 0; i < cards.length; i++ ) {
newHtml += buildCardHtml( cards[ i ], colKey );
}
$cards.html( newHtml );
}
/**
* Apply a server snapshot to all four columns.
*
* @param {Object} snapshot Board snapshot from AJAX.
* @returns {void}
*/
function applySnapshot( snapshot ) {
renderColumn( 'generating', snapshot.generating || [] );
renderColumn( 'in_review', snapshot.in_review || [] );
renderColumn( 'published', snapshot.published || [] );
renderColumn( 'failed', snapshot.failed || [] );
}
/**
* Compute the next poll delay based on whether there are active runs.
*
* @param {boolean} hasActiveRuns
* @returns {number} Milliseconds until next poll.
*/
function nextDelay( hasActiveRuns ) {
if ( hasActiveRuns ) {
backoffFactor = 1;
return baseInterval;
}
backoffFactor = Math.min( backoffFactor + 1, maxBackoff );
return baseInterval * backoffFactor;
}
/**
* Schedule the next poll after a given delay.
*
* @param {number} delay Milliseconds.
* @returns {void}
*/
function schedulePoll( delay ) {
clearTimeout( pollTimer );
pollTimer = setTimeout( doPoll, delay );
}
/**
* Execute one board poll: AJAX POST → apply snapshot → reschedule.
*
* @returns {void}
*/
function doPoll() {
$.ajax( {
url: cfg.ajaxUrl,
method: 'POST',
data: {
action: cfg.action,
nonce: cfg.nonce
},
success: function ( response ) {
if ( $errorBanner ) {
$errorBanner.removeClass( 'is-visible' );
}
if ( response && response.success && response.data ) {
applySnapshot( response.data );
schedulePoll( nextDelay( !! response.data.has_active_runs ) );
} else {
schedulePoll( nextDelay( false ) );
}
},
error: function () {
if ( $errorBanner ) {
$errorBanner.addClass( 'is-visible' );
}
schedulePoll( nextDelay( false ) );
}
} );
}
/** 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 ) );

8
includes/admin/class-admin-page.php Executable file → Normal file
View File

@@ -62,6 +62,7 @@ class PRAutoBlogger_Admin_Page {
public function on_enqueue_assets( string $hook_suffix ): void {
$pages = array(
'toplevel_page_' . self::PAGE_SLUG,
'prautoblogger_page_prautoblogger-board',
'prautoblogger_page_prautoblogger-metrics',
'prautoblogger_page_prautoblogger-review-queue',
'prautoblogger_page_prautoblogger-ideas',
@@ -323,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_reasoning_max_tokens' );
$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' );
if ( in_array( $option_name, $numeric, true ) ) {
return is_numeric( $value ) ? $value : 0;
}
@@ -346,7 +347,4 @@ class PRAutoBlogger_Admin_Page {
update_option( 'prautoblogger_image_provider', $provider );
return $candidate;
}
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), esc_html( $candidate ) ) );
return (string) get_option( 'prautoblogger_image_model', PRAUTOBLOGGER_DEFAULT_IMAGE_MODEL );
}
}
add_settings_error( 'prautoblogger_image_model', 'prautoblogger_image_model_unknown', sprintf( esc_html__( 'Image model "%s" is not in the registry. Keeping the previous selection.', 'prautoblogger' ), es

View File

@@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
/**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
*
* Supplies card data for the kanban board page from existing DB tables.
*
* Queries `prab_generation_log`, WordPress post meta, and the generation
* status transient to compute the four board columns:
* Generating | In Review | Published | Failed
*
* No schema changes — all data comes from tables that already exist.
*
* Triggered by: PRAutoBlogger_Board_Page::on_ajax_board_status().
* Dependencies: WordPress $wpdb, WP_Query, get_option().
*
* @see admin/class-board-page.php — Calls get_board_snapshot().
* @see core/class-cost-reporter.php — Parallel cost-query pattern.
* @see ARCHITECTURE.md — Database schema (generation_log, post meta).
*/
class PRAutoBlogger_Board_Data_Provider {
/** Seconds of gen-log inactivity after which a run is no longer "generating". */
private const GENERATING_GRACE_SECONDS = 1800;
/** Transient key written by PRAutoBlogger_Executor for the active generation run. */
private const STATUS_TRANSIENT = 'prautoblogger_generation_status';
/**
* Return the full board snapshot consumed by the AJAX poller.
*
* Each column is an array of card objects. Cards contain only the data
* needed to render the board card and provide click-throughs.
*
* @return array{
* generating: array<int, array<string, mixed>>,
* in_review: array<int, array<string, mixed>>,
* published: array<int, array<string, mixed>>,
* failed: array<int, array<string, mixed>>,
* has_active_runs: bool,
* }
*/
public function get_board_snapshot(): array {
$generating = $this->get_generating_cards();
$in_review = $this->get_in_review_cards();
$published = $this->get_published_cards();
$failed = $this->get_failed_cards();
return array(
'generating' => $generating,
'in_review' => $in_review,
'published' => $published,
'failed' => $failed,
'has_active_runs' => ! empty( $generating ),
);
}
/**
* Cards for runs that are currently in-progress.
*
* Detection strategy: check the generation status transient first (authoritative
* for the active manual-run or daily-cron run), then fall back to recent gen_log
* run_ids that have no associated published/draft post yet.
*
* @return array<int, array<string, mixed>>
*/
public function get_generating_cards(): array {
global $wpdb;
$cards = array();
// Primary: check if the executor transient signals an active run.
$status = get_transient( self::STATUS_TRANSIENT );
if ( is_array( $status ) && 'running' === ( $status['status'] ?? '' ) ) {
$cards[] = array(
'run_id' => '',
'post_id' => 0,
'title' => $status['stage'] ?? __( 'Generating…', 'prautoblogger' ),
'cost_total' => 0.0,
'stage_current' => $status['stage'] ?? '',
'stage_count' => 0,
'started_at' => $status['started'] ?? 0,
'click_action' => 'logs',
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
);
}
// Secondary: runs in gen_log that are recent and have no linked post.
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$since = gmdate( 'Y-m-d H:i:s', time() - self::GENERATING_GRACE_SECONDS );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$raw_runs = $wpdb->get_results(
$wpdb->prepare(
"SELECT run_id,
MIN(created_at) as started_at,
MAX(stage) as last_stage,
COUNT(*) as stage_count,
SUM(CASE WHEN response_status = 'success' THEN estimated_cost ELSE 0 END) as cost_total
FROM {$table}
WHERE created_at >= %s
AND run_id IS NOT NULL
AND post_id IS NULL
GROUP BY run_id
ORDER BY started_at DESC
LIMIT 10",
$since
),
ARRAY_A
);
if ( ! is_array( $raw_runs ) ) {
return $cards;
}
foreach ( $raw_runs as $row ) {
$run_id = (string) $row['run_id'];
if ( '' === $run_id ) {
continue;
}
// Skip if we already have a transient card (avoid duplicate).
if ( ! empty( $cards ) && '' === $cards[0]['run_id'] ) {
// Only add secondary cards if we have a real run_id.
// The transient card already covers the active run.
}
$cards[] = array(
'run_id' => $run_id,
'post_id' => 0,
'title' => sprintf(
/* translators: %s: pipeline stage name */
__( 'Generating — %s', 'prautoblogger' ),
esc_html( ucfirst( (string) $row['last_stage'] ) )
),
'cost_total' => round( (float) $row['cost_total'], 6 ),
'stage_current' => (string) $row['last_stage'],
'stage_count' => (int) $row['stage_count'],
'started_at' => strtotime( (string) $row['started_at'] ),
'click_action' => 'logs',
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
);
}
return $cards;
}
/**
* Cards for generated draft posts awaiting human review.
*
* @return array<int, array<string, mixed>>
*/
public function get_in_review_cards(): array {
$query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'draft',
'posts_per_page' => 20,
'meta_query' => array(
array(
'key' => '_prautoblogger_generated',
'value' => '1',
),
),
'orderby' => 'date',
'order' => 'DESC',
)
);
$cards = array();
foreach ( $query->posts as $post ) {
if ( ! ( $post instanceof WP_Post ) ) {
continue;
}
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
$cards[] = array(
'run_id' => $run_id,
'post_id' => $post->ID,
'title' => $post->post_title,
'cost_total' => $cost_total,
'created_at' => strtotime( $post->post_date_gmt ),
'click_action' => 'review',
'review_url' => admin_url( 'admin.php?page=prautoblogger-review-queue' ),
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
);
}
return $cards;
}
/**
* Cards for published posts within the configured window.
*
* @return array<int, array<string, mixed>>
*/
public function get_published_cards(): array {
$window_days = max( 1, (int) get_option( 'prautoblogger_board_published_window_days', 7 ) );
$date_after = gmdate( 'Y-m-d H:i:s', time() - ( $window_days * DAY_IN_SECONDS ) );
$query = new WP_Query(
array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 20,
'date_query' => array(
array(
'after' => $date_after,
'inclusive' => true,
),
),
'meta_query' => array(
array(
'key' => '_prautoblogger_generated',
'value' => '1',
),
),
'orderby' => 'date',
'order' => 'DESC',
)
);
$cards = array();
foreach ( $query->posts as $post ) {
if ( ! ( $post instanceof WP_Post ) ) {
continue;
}
$run_id = (string) get_post_meta( $post->ID, '_prautoblogger_run_id', true );
$cost_total = (float) get_post_meta( $post->ID, '_prautoblogger_total_cost', true );
$cards[] = array(
'run_id' => $run_id,
'post_id' => $post->ID,
'title' => $post->post_title,
'cost_total' => $cost_total,
'published_at' => strtotime( $post->post_date_gmt ),
'click_action' => 'edit',
'edit_url' => get_edit_post_link( $post->ID, 'raw' ),
'post_url' => get_permalink( $post->ID ),
);
}
return $cards;
}
/**
* Cards for generation runs that ended in an error state.
*
* Looks for recent gen_log rows with response_status='error' that have
* no corresponding published post — these are pipeline failures.
*
* @return array<int, array<string, mixed>>
*/
public function get_failed_cards(): array {
global $wpdb;
$table = $wpdb->prefix . 'prautoblogger_generation_log';
$since = gmdate( 'Y-m-d H:i:s', time() - ( 7 * DAY_IN_SECONDS ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$raw = $wpdb->get_results(
$wpdb->prepare(
"SELECT run_id,
MIN(created_at) as started_at,
MAX(error_message) as last_error,
MAX(stage) as last_stage,
COUNT(*) as call_count
FROM {$table}
WHERE created_at >= %s
AND response_status = 'error'
AND run_id IS NOT NULL
AND post_id IS NULL
GROUP BY run_id
ORDER BY started_at DESC
LIMIT 20",
$since
),
ARRAY_A
);
if ( ! is_array( $raw ) ) {
return array();
}
$cards = array();
foreach ( $raw as $row ) {
$run_id = (string) $row['run_id'];
if ( '' === $run_id ) {
continue;
}
$cards[] = array(
'run_id' => $run_id,
'post_id' => 0,
'title' => sprintf(
/* translators: %s: pipeline stage name */
__( 'Failed — %s', 'prautoblogger' ),
esc_html( ucfirst( (string) $row['last_stage'] ) )
),
'error_message' => (string) $row['last_error'],
'started_at' => strtotime( (string) $row['started_at'] ),
'click_action' => 'logs',
'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ),
);
}
return $cards;
}
}

View File

@@ -0,0 +1,150 @@
<?php
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.
*
* 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.
*
* Card click-throughs (M1):
* - 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).
*/
class PRAutoBlogger_Board_Page {
private const PAGE_SLUG = 'prautoblogger-board';
/** AJAX action name for board polling. */
public const AJAX_ACTION = 'prautoblogger_board_status';
/** Nonce action string for board AJAX. */
public const NONCE_ACTION = 'prautoblogger_board';
/**
* Register the Board as the PRIMARY (first) submenu item, overriding the
* implicit Settings landing page. The trick is: WordPress auto-generates a
* submenu duplicate of the top-level page; we name it "Board" so the
* first click goes to the board, not the raw settings page.
*
* @return void
*/
public function on_register_menu(): void {
// Override the auto-created "PRAutoBlogger" submenu entry with "Board".
add_submenu_page(
'prautoblogger-settings',
__( 'PRAutoBlogger Board', 'prautoblogger' ),
__( 'Board', 'prautoblogger' ),
'manage_options',
self::PAGE_SLUG,
array( $this, 'render_page' )
);
}
/**
* Render the board page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/board-page.php';
}
/**
* Enqueue board-specific JS + CSS and localize the poller config.
*
* Called via `admin_enqueue_scripts` action, gated to this page's hook suffix.
*
* @param string $hook_suffix Current admin page hook suffix.
* @return void
*/
public function on_enqueue_assets( string $hook_suffix ): void {
$board_hook = 'prautoblogger_page_' . self::PAGE_SLUG;
if ( $hook_suffix !== $board_hook ) {
return;
}
wp_enqueue_style(
'prautoblogger-board',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/board.css',
array( 'prautoblogger-admin' ),
PRAUTOBLOGGER_VERSION
);
wp_enqueue_script(
'prautoblogger-board',
PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/board.js',
array( 'jquery' ),
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 ) );
wp_localize_script(
'prautoblogger-board',
'prabBoard',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( self::NONCE_ACTION ),
'pollInterval' => $poll_interval * 1000,
'publishedWindowDays' => $published_days,
'action' => self::AJAX_ACTION,
'strings' => array(
'generating' => __( 'Generating', 'prautoblogger' ),
'inReview' => __( 'In Review', 'prautoblogger' ),
'published' => __( 'Published', 'prautoblogger' ),
'failed' => __( 'Failed', 'prautoblogger' ),
'empty' => __( 'Nothing here yet.', 'prautoblogger' ),
'cost' => __( 'Cost', 'prautoblogger' ),
'view' => __( 'View', 'prautoblogger' ),
'edit' => __( 'Edit', 'prautoblogger' ),
'viewLog' => __( 'View Log', 'prautoblogger' ),
'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ),
),
)
);
}
/**
* AJAX handler: return the current board snapshot (all four columns).
*
* Nonce: `prautoblogger_board`. Requires `manage_options` capability.
*
* Side effects: DB reads only — no writes.
*
* @return void
*/
public function on_ajax_board_status(): void {
check_ajax_referer( self::NONCE_ACTION, 'nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
$provider = new PRAutoBlogger_Board_Data_Provider();
$snapshot = $provider->get_board_snapshot();
wp_send_json_success( $snapshot );
}
}

View File

@@ -78,17 +78,29 @@ class PRAutoBlogger_Settings_Fields_Extended {
'low' => __( 'Low — light reasoning, lower cost', 'prautoblogger' ),
'minimal' => __( 'Minimal — barely any reasoning', 'prautoblogger' ),
),
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above. When the Reasoning Token Cap below is set (default), the cap is sent instead of the effort level — set the cap to 0 to use effort only.', 'prautoblogger' ),
'description' => __( 'How much effort the model spends reasoning before answering. Higher effort = more reasoning tokens = higher cost. Only applies when reasoning is enabled above.', 'prautoblogger' ),
),
// ── Board Settings ─────────────────────────────────────────
array(
'id' => 'prautoblogger_board_poll_interval',
'label' => __( 'Board Poll Interval (seconds)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => 5,
'min' => 3,
'max' => 60,
'description' => __( 'How often the kanban board polls for status updates (seconds). Minimum 3s. Doubles automatically when no article is actively generating.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_reasoning_max_tokens',
'label' => __( 'Reasoning Token Cap', 'prautoblogger' ),
'id' => 'prautoblogger_board_published_window_days',
'label' => __( 'Published Column Window (days)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_models',
'default' => PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS,
'min' => 0,
'max' => 32000,
'description' => __( 'Hard ceiling on thinking tokens per call when reasoning is enabled. The completion budget is raised by this amount so reasoning can never crowd out the article text — an uncapped high effort can spend the entire budget thinking and return an empty draft. 0 = no cap (uncapped effort mode, not recommended).', 'prautoblogger' ),
'section' => 'prautoblogger_schedule',
'default' => 7,
'min' => 1,
'max' => 90,
'description' => __( 'How many days back the Published column shows articles. Increase to see older published articles on the board.', 'prautoblogger' ),
),
// ── Schedule & Budget ───────────────────────────────────────
@@ -123,26 +135,6 @@ class PRAutoBlogger_Settings_Fields_Extended {
'step' => '0.01',
'description' => __( 'Hard stop when reached. 0 = unlimited.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_request_json_retention_days',
'label' => __( 'Audit Payload Retention (days)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS,
'min' => 0,
'step' => '1',
'description' => __( 'Heavy audit payloads (request_json on the generation log, stage output snapshots) are emptied after this many days by the daily cleanup cron. Cost/token rows are kept forever. 0 = keep payloads forever.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_per_run_cost_ceiling_usd',
'label' => __( 'Per-Run Cost Ceiling (USD)', 'prautoblogger' ),
'type' => 'number',
'section' => 'prautoblogger_schedule',
'default' => PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD,
'min' => 0,
'step' => '0.01',
'description' => __( 'Hard per-run stop: every API call reserves its worst-case cost against this ceiling before dispatch; a breach halts the run and routes it to the Review Queue. Snapshotted at run start. 0 = disabled.', 'prautoblogger' ),
),
// ── Publishing ──────────────────────────────────────────────
array(
@@ -298,27 +290,4 @@ class PRAutoBlogger_Settings_Fields_Extended {
),
array(
'id' => 'prautoblogger_image_compose_variants',
'label' => __( 'Composed Variant Set', 'prautoblogger' ),
'type' => 'text',
'section' => 'prautoblogger_images',
'default' => 'og,square',
'description' => __( 'Comma-separated variants to render for Image A. Supported: og (1200×630 social share), square (1080×1080 card). Unknown values are ignored.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_image_featured_mark_enabled',
'label' => __( 'Featured Image Corner Mark', 'prautoblogger' ),
'type' => 'toggle',
'section' => 'prautoblogger_images',
'default' => '1',
'description' => __( 'Overlay a small semi-transparent Peptide Repo mark in the bottom-right corner of featured images, so scraped or re-shared images carry attribution.', 'prautoblogger' ),
),
array(
'id' => 'prautoblogger_runware_model_catalog',
'label' => __( 'Runware Model Catalog', 'prautoblogger' ),
'type' => 'runware_catalog_sync',
'section' => 'prautoblogger_images',
'description' => __( 'Live model catalog sync status and on-demand refresh.', 'prautoblogger' ),
),
);
}
}
'label' => __( 'Composed Variant Set', 'prautoblogge

View File

@@ -46,10 +46,6 @@ class PRAutoBlogger {
$this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() );
add_action( 'admin_init', array( $this, 'on_check_db_version' ) );
// v0.18.0: cron requests must also self-heal the schema — after a
// deploy, scheduled generation can run before any admin page load,
// and new-column inserts would silently fail on the old schema.
add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) );
add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) );
if ( is_admin() ) {
@@ -67,6 +63,11 @@ class PRAutoBlogger {
/** Register admin-only hooks (settings, notices, metabox, dashboard widget). */
private function register_admin_hooks(): void {
// Kanban board — registered first so it appears as the first submenu item.
$board_page = new PRAutoBlogger_Board_Page();
add_action( 'admin_menu', array( $board_page, 'on_register_menu' ) );
add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) );
$admin_page = new PRAutoBlogger_Admin_Page();
add_action( 'admin_menu', array( $admin_page, 'on_register_menu' ) );
add_action( 'admin_init', array( $admin_page, 'on_register_settings' ) );
@@ -154,10 +155,6 @@ class PRAutoBlogger {
// pipeline dies before amortize_research_costs runs).
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) );
// v0.18.0: stuck-run sweep + audit-payload retention rides the SAME
// existing daily cron (no new schedule). See class-run-reaper.php.
add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) );
// v0.8.1: WP-CLI manual trigger for the reaper. Only registers when
// WP-CLI is present; no-op in normal HTTP requests.
if ( defined( 'WP_CLI' ) && WP_CLI ) {
@@ -197,6 +194,12 @@ class PRAutoBlogger {
add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) );
// Board status polling AJAX (nonce: prautoblogger_board).
add_action(
'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION,
array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' )
);
$ideas = new PRAutoBlogger_Ideas_Browser();
add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) );
add_action( 'wp_ajax_prautoblogger_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) );
@@ -310,21 +313,6 @@ class PRAutoBlogger {
}
}
/**
* Run the db-version check on cron requests (admin_init never fires
* there). Gated to wp_doing_cron() so normal frontend requests pay
* nothing beyond one autoloaded option read.
*
* Side effects: may run the same migrations as on_check_db_version().
*
* @return void
*/
public function on_check_db_version_for_cron(): void {
if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
$this->on_check_db_version();
}
}
/**
* Add custom cron schedules (six-hourly for metrics collection).
*
@@ -373,15 +361,4 @@ class PRAutoBlogger {
public function on_collect_metrics(): void {
$this->executor->on_collect_metrics(); }
/** @see PRAutoBlogger_Executor::on_ajax_generate_now() */
public function on_ajax_generate_now(): void {
$this->executor->on_ajax_generate_now(); }
/** @see PRAutoBlogger_Ajax_Handlers::on_ajax_test_connection() */
public function on_ajax_test_connection(): void {
$this->ajax_handlers->on_ajax_test_connection(); }
/** @see PRAutoBlogger_Executor::get_model_registry() */
public function get_model_registry(): PRAutoBlogger_OpenRouter_Model_Registry {
return $this->executor->get_model_registry(); }
}
/** @see PRAutoBlogger_Execut

View File

@@ -64,8 +64,8 @@ class PRAutoBlogger_Cost_Reporter {
$start_date = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
FROM {$table}
@@ -102,8 +102,8 @@ class PRAutoBlogger_Cost_Reporter {
$table = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$results = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare(
"SELECT stage,
SUM(estimated_cost) as total_cost,
@@ -142,62 +142,3 @@ class PRAutoBlogger_Cost_Reporter {
}
return ( $this->get_monthly_spend() / $budget ) * 100.0;
}
/**
* Get average input/output token counts for given stages over a time period.
*
* Used by the model picker field renderer to calculate estimated costs
* per generation based on historical token usage. Maps setting IDs to their
* constituting stages (e.g., writing model controls outline + draft + polish).
* Moved here from Cost_Tracker in v0.18.0 (read-only reporting query;
* Cost_Tracker keeps a delegating wrapper for API compatibility).
*
* @param array<string> $stages Stage names ('analysis', 'outline', 'draft', 'polish', 'review').
* @param int $days Historical window (default 30 days).
*
* @return array{avg_prompt_tokens: float, avg_completion_tokens: float, sample_size: int}
* Returns empty counters if no history. Never throws.
*/
public function get_avg_tokens_for_stages( array $stages, int $days = 30 ): array {
global $wpdb;
if ( empty( $stages ) ) {
return array(
'avg_prompt_tokens' => 0.0,
'avg_completion_tokens' => 0.0,
'sample_size' => 0,
);
}
$cutoff_datetime = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
$stage_list = implode( ',', array_map( array( $wpdb, 'prepare' ), array_fill( 0, count( $stages ), '%s' ), $stages ) );
$table_name = $wpdb->prefix . 'prautoblogger_generation_log';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Prepared stage list via array_map + prepare
$query = $wpdb->prepare(
"SELECT AVG(prompt_tokens) as avg_prompt,
AVG(completion_tokens) as avg_completion,
COUNT(*) as sample_count
FROM $table_name
WHERE stage IN ( $stage_list )
AND created_at >= %s",
$cutoff_datetime
);
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- phpcs pragma above
$result = $wpdb->get_row( $query, ARRAY_A );
if ( ! $result || 0 === (int) ( $result['sample_count'] ?? 0 ) ) {
return array(
'avg_prompt_tokens' => 0.0,
'avg_completion_tokens' => 0.0,
'sample_size' => 0,
);
}
return array(
'avg_prompt_tokens' => (float) ( $result['avg_prompt'] ?? 0 ),
'avg_completion_tokens' => (float) ( $result['avg_completion'] ?? 0 ),
'sample_size' => (int) ( $result['sample_count'] ?? 0 ),
);
}
}

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.18.3
* Version: 0.19.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,31 +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.18.3' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' );
define( 'PRAUTOBLOGGER_VERSION', '0.19.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
// API and retry limits — named constants instead of magic numbers.
// SAFETY INVARIANT: API_TIMEOUT_SECONDS × MAX_RETRIES must stay < STATUS_TTL (600s,
// defined in PRAutoBlogger_Generation_Status_Poller). The current product
// (180s × 3 = 540s) leaves a 60s margin. If either constant is raised, verify the
// inequality still holds — otherwise a fully-retrying LLM call can exhaust the
// status transient TTL within a single stage and trigger a false orphan-run abort.
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
// Pipeline v2 Phase 1 (v0.18.0) defaults. Both are SETTINGS-backed at every
// read site (prautoblogger_per_run_cost_ceiling_usd /
// prautoblogger_request_json_retention_days); these constants exist only as
// the named get_option() fallbacks — never inline the literals.
define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 );
define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 );
// 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
// message (the 2026-06-11 empty-draft incident). SETTINGS-backed
// (prautoblogger_reasoning_max_tokens); 0 disables the cap (pure effort mode).
define( 'PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS', 2048 );
define( 'PRAUTOBLOGGER_RETRY_BASE_DELAY_SECONDS', 2 );
define( 'PRAUTOBLOGGER_API_TIMEOUT_SECONDS', 180 );
define( 'PRAUTOBLOGGER_CACHE_TTL_SECONDS', 3600 );

View File

@@ -0,0 +1,160 @@
<?php
/**
* Kanban board template — primary landing screen for PRAutoBlogger.
*
* Renders the four-column board: Generating | In Review | Published | Failed.
* Cards are populated on first load by a synchronous PHP data call, then kept
* live by the board.js poller (AJAX, settings-backed interval).
*
* @see admin/class-board-page.php — Renders this template.
* @see admin/class-board-data-provider.php — Supplies card data.
* @see assets/js/board.js — Polling + DOM updates.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Eager load on first render so the board is not blank on arrival.
$provider = new PRAutoBlogger_Board_Data_Provider();
$snapshot = $provider->get_board_snapshot();
/**
* Render a single board card as HTML.
*
* @param array<string, mixed> $card Card data from Board_Data_Provider.
* @param string $column_key Column identifier (generating|in_review|published|failed).
* @return void
*/
function prab_render_board_card( array $card, string $column_key ): void {
$title = ! empty( $card['title'] ) ? esc_html( $card['title'] ) : esc_html__( 'Untitled', 'prautoblogger' );
$cost = isset( $card['cost_total'] ) ? '$' . esc_html( number_format( (float) $card['cost_total'], 4 ) ) : '';
$click_url = '';
$link_text = '';
if ( 'review' === ( $card['click_action'] ?? '' ) ) {
$click_url = esc_url( $card['review_url'] ?? '' );
$link_text = esc_html__( 'Review Queue', 'prautoblogger' );
} elseif ( 'edit' === ( $card['click_action'] ?? '' ) ) {
$click_url = esc_url( $card['edit_url'] ?? '' );
$link_text = esc_html__( 'Edit Post', 'prautoblogger' );
} elseif ( 'logs' === ( $card['click_action'] ?? '' ) ) {
$click_url = esc_url( $card['log_url'] ?? '' );
$link_text = esc_html__( 'View Log', 'prautoblogger' );
}
$is_generating = ( 'generating' === $column_key );
$card_class = 'prab-board-card';
if ( $is_generating ) {
$card_class .= ' prab-board-card--generating';
}
?>
<div class="<?php echo esc_attr( $card_class ); ?>"
data-run-id="<?php echo esc_attr( (string) ( $card['run_id'] ?? '' ) ); ?>"
data-post-id="<?php echo esc_attr( (string) ( $card['post_id'] ?? 0 ) ); ?>">
<?php if ( $is_generating ) : ?>
<div class="prab-card-spinner" aria-label="<?php esc_attr_e( 'Generating in progress', 'prautoblogger' ); ?>"></div>
<?php endif; ?>
<div class="prab-card-title"><?php echo $title; // Already escaped above. ?></div>
<?php if ( ! empty( $cost ) ) : ?>
<div class="prab-card-meta">
<span class="prab-card-cost"><?php echo $cost; // Already escaped above. ?></span>
</div>
<?php endif; ?>
<?php if ( $is_generating && ! empty( $card['stage_current'] ) ) : ?>
<div class="prab-card-stage">
<?php
printf(
/* translators: %s: pipeline stage name */
esc_html__( 'Stage: %s', 'prautoblogger' ),
esc_html( ucfirst( (string) $card['stage_current'] ) )
);
?>
</div>
<?php endif; ?>
<?php if ( ! empty( $card['error_message'] ) ) : ?>
<div class="prab-card-error">
<?php echo esc_html( wp_trim_words( (string) $card['error_message'], 12, '…' ) ); ?>
</div>
<?php endif; ?>
<?php if ( ! empty( $click_url ) ) : ?>
<div class="prab-card-actions">
<a href="<?php echo $click_url; // Already escaped above. ?>" class="prab-card-link">
<?php echo $link_text; // Already escaped above. ?>
</a>
</div>
<?php endif; ?>
</div>
<?php
}
?>
<div class="wrap prab-board-wrap">
<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>
</h1>
<div id="prab-board" class="prab-board" aria-live="polite" aria-label="<?php esc_attr_e( 'Article generation board', 'prautoblogger' ); ?>">
<?php
$columns = array(
'generating' => array(
'label' => __( 'Generating', 'prautoblogger' ),
'pill' => 'prab-pill--generating',
'cards' => $snapshot['generating'],
),
'in_review' => array(
'label' => __( 'In Review', 'prautoblogger' ),
'pill' => 'prab-pill--review',
'cards' => $snapshot['in_review'],
),
'published' => array(
'label' => __( 'Published', 'prautoblogger' ),
'pill' => 'prab-pill--published',
'cards' => $snapshot['published'],
),
'failed' => array(
'label' => __( 'Failed', 'prautoblogger' ),
'pill' => 'prab-pill--failed',
'cards' => $snapshot['failed'],
),
);
foreach ( $columns as $col_key => $col ) :
$count = count( $col['cards'] );
?>
<div class="prab-board-column" data-column="<?php echo esc_attr( $col_key ); ?>">
<div class="prab-col-header">
<span class="prab-col-title"><?php echo esc_html( $col['label'] ); ?></span>
<span class="prab-col-count prab-pill <?php echo esc_attr( $col['pill'] ); ?>"><?php echo esc_html( (string) $count ); ?></span>
</div>
<div class="prab-col-cards" data-column-cards="<?php echo esc_attr( $col_key ); ?>">
<?php if ( empty( $col['cards'] ) ) : ?>
<p class="prab-col-empty"><?php esc_html_e( 'Nothing here yet.', 'prautoblogger' ); ?></p>
<?php else : ?>
<?php foreach ( $col['cards'] as $card ) : ?>
<?php prab_render_board_card( $card, $col_key ); ?>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div><!-- #prab-board -->
<p class="prab-board-footer">
<?php
$interval_secs = max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) );
printf(
/* translators: %d: poll interval in seconds */
esc_html__( 'Board refreshes every %d seconds while an article is generating. Configure in Settings → Schedule & Budget.', 'prautoblogger' ),
$interval_secs
);
?>
</p>
</div><!-- .prab-board-wrap -->

View File

@@ -0,0 +1,294 @@
<?php
/**
* Tests for PRAutoBlogger_Board_Data_Provider — column/state bucket mapping.
*
* Seeds mock DB results and WordPress post data, then asserts that each
* board column receives the correct cards. No live DB required; uses Brain\Monkey
* and a mock $wpdb.
*
* @package PRAutoBlogger\Tests\Admin
*/
namespace PRAutoBlogger\Tests\Admin;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class BoardDataProviderTest extends BaseTestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
private $wpdb;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
Functions\when( 'get_option' )->alias(
function ( string $name, $default = false ) {
$map = array(
'prautoblogger_board_published_window_days' => 7,
'prautoblogger_board_poll_interval' => 5,
);
return $map[ $name ] ?? $default;
}
);
// Transient stub: by default no active run.
Functions\when( 'get_transient' )->justReturn( false );
// WP_Query stub: return empty by default; individual tests override.
Functions\when( 'WP_Query' )->justReturn( (object) array( 'posts' => array() ) );
// Admin URL stub.
Functions\when( 'admin_url' )->alias(
function ( string $path = '' ) {
return 'https://test.example.com/wp-admin/' . ltrim( $path, '/' );
}
);
// DAY_IN_SECONDS constant.
if ( ! defined( 'DAY_IN_SECONDS' ) ) {
define( 'DAY_IN_SECONDS', 86400 );
}
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
// ── Generating cards ────────────────────────────────────────────────────
/**
* A running transient with status='running' yields a Generating card.
*/
public function test_generating_cards_from_transient(): void {
Functions\when( 'get_transient' )->alias(
function ( $key ) {
if ( 'prautoblogger_generation_status' === $key ) {
return array(
'status' => 'running',
'stage' => 'Drafting article…',
'started' => time() - 60,
);
}
return false;
}
);
// No gen_log secondary runs needed.
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn( array() );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_generating_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 'Drafting article…', $cards[0]['stage_current'] );
$this->assertSame( 'logs', $cards[0]['click_action'] );
}
/**
* When the transient is idle, orphan gen_log rows produce Generating cards.
*/
public function test_generating_cards_from_gen_log(): void {
Functions\when( 'get_transient' )->justReturn( false );
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn(
array(
array(
'run_id' => 'abc-123',
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 120 ),
'last_stage' => 'draft',
'stage_count' => '3',
'cost_total' => '0.005000',
),
)
);
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_generating_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 'abc-123', $cards[0]['run_id'] );
$this->assertSame( 'draft', $cards[0]['stage_current'] );
$this->assertEqualsWithDelta( 0.005, $cards[0]['cost_total'], 0.0001 );
}
/**
* An idle transient + empty gen_log yields zero Generating cards.
*/
public function test_no_generating_cards_when_idle(): void {
Functions\when( 'get_transient' )->justReturn( false );
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn( array() );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_generating_cards();
$this->assertCount( 0, $cards );
}
// ── In Review cards ─────────────────────────────────────────────────────
/**
* Draft posts with the _prautoblogger_generated meta appear in In Review.
*/
public function test_in_review_cards_from_draft_posts(): void {
$post = new \stdClass();
$post->ID = 42;
$post->post_title = 'Test Draft Post';
$post->post_date_gmt = '2026-06-12 10:00:00';
// WP_Query returns our fake post.
Functions\when( 'WP_Query' )->justReturn(
(object) array( 'posts' => array( $post ) )
);
Functions\when( 'get_post_meta' )->alias(
function ( $post_id, $key, $single ) use ( $post ) {
if ( $post_id === $post->ID ) {
if ( '_prautoblogger_run_id' === $key ) {
return 'run-draft-999';
}
if ( '_prautoblogger_total_cost' === $key ) {
return '0.012000';
}
}
return '';
}
);
Functions\when( 'get_edit_post_link' )->justReturn(
'https://test.example.com/wp-admin/post.php?post=42&action=edit'
);
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_in_review_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 42, $cards[0]['post_id'] );
$this->assertSame( 'Test Draft Post', $cards[0]['title'] );
$this->assertSame( 'review', $cards[0]['click_action'] );
$this->assertEqualsWithDelta( 0.012, $cards[0]['cost_total'], 0.0001 );
}
// ── Published cards ─────────────────────────────────────────────────────
/**
* Published posts within the window appear in Published.
*/
public function test_published_cards_within_window(): void {
$post = new \stdClass();
$post->ID = 77;
$post->post_title = 'Published Article';
$post->post_date_gmt = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
Functions\when( 'WP_Query' )->justReturn(
(object) array( 'posts' => array( $post ) )
);
Functions\when( 'get_post_meta' )->alias(
function ( $post_id, $key, $single ) use ( $post ) {
if ( $post_id === $post->ID ) {
if ( '_prautoblogger_run_id' === $key ) {
return 'run-pub-77';
}
if ( '_prautoblogger_total_cost' === $key ) {
return '0.020000';
}
}
return '';
}
);
Functions\when( 'get_edit_post_link' )->justReturn(
'https://test.example.com/wp-admin/post.php?post=77&action=edit'
);
Functions\when( 'get_permalink' )->justReturn( 'https://test.example.com/?p=77' );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_published_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 77, $cards[0]['post_id'] );
$this->assertSame( 'edit', $cards[0]['click_action'] );
}
// ── Failed cards ────────────────────────────────────────────────────────
/**
* Gen_log error rows with no post produce Failed cards.
*/
public function test_failed_cards_from_error_log(): void {
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn(
array(
array(
'run_id' => 'fail-run-001',
'started_at' => gmdate( 'Y-m-d H:i:s', time() - 3600 ),
'last_error' => 'OpenRouter rate limit exceeded',
'last_stage' => 'draft',
'call_count' => '2',
),
)
);
$provider = new \PRAutoBlogger_Board_Data_Provider();
$cards = $provider->get_failed_cards();
$this->assertCount( 1, $cards );
$this->assertSame( 'fail-run-001', $cards[0]['run_id'] );
$this->assertStringContainsString( 'rate limit', $cards[0]['error_message'] );
$this->assertSame( 'logs', $cards[0]['click_action'] );
}
// ── Full snapshot ────────────────────────────────────────────────────────
/**
* get_board_snapshot() has_active_runs = true when Generating is non-empty.
*/
public function test_snapshot_has_active_runs_flag(): void {
Functions\when( 'get_transient' )->alias(
function ( $key ) {
if ( 'prautoblogger_generation_status' === $key ) {
return array(
'status' => 'running',
'stage' => 'Writing…',
'started' => time(),
);
}
return false;
}
);
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn( array() );
Functions\when( 'WP_Query' )->justReturn( (object) array( 'posts' => array() ) );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$snapshot = $provider->get_board_snapshot();
$this->assertTrue( $snapshot['has_active_runs'] );
}
/**
* get_board_snapshot() has_active_runs = false when Generating is empty.
*/
public function test_snapshot_no_active_runs_when_idle(): void {
Functions\when( 'get_transient' )->justReturn( false );
$this->wpdb->method( 'prepare' )->willReturn( 'SELECT 1' );
$this->wpdb->method( 'get_results' )->willReturn( array() );
Functions\when( 'WP_Query' )->justReturn( (object) array( 'posts' => array() ) );
$provider = new \PRAutoBlogger_Board_Data_Provider();
$snapshot = $provider->get_board_snapshot();
$this->assertFalse( $snapshot[

View File

@@ -0,0 +1,122 @@
<?php
/**
* Tests verifying the SQL inline-comment bug fix in PRAutoBlogger_Cost_Reporter.
*
* The original code embedded PHP `// phpcs:ignore` annotations inside SQL string
* literals, passing them to MySQL as literal comment text. These tests confirm the
* SQL strings passed to $wpdb->prepare() no longer contain inline PHP comments.
*
* Kept separate from CostReporterTest to isolate the bug-fix regression signal.
*
* @package PRAutoBlogger\Tests\Core
*/
namespace PRAutoBlogger\Tests\Core;
use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions;
class CostReporterSqlBugFixTest extends BaseTestCase {
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
private $wpdb;
protected function setUp(): void {
parent::setUp();
$this->wpdb = $this->create_mock_wpdb();
$GLOBALS['wpdb'] = $this->wpdb;
Functions\when( 'get_option' )->alias(
function ( $name, $default = false ) {
return 'prautoblogger_monthly_budget_usd' === $name ? '50.00' : $default;
}
);
}
protected function tearDown(): void {
unset( $GLOBALS['wpdb'] );
parent::tearDown();
}
/**
* get_daily_spend() must not pass inline PHP comments to prepare().
*
* Captures the SQL argument passed to $wpdb->prepare() and asserts it
* contains no `// phpcs:ignore` text (which would be literal SQL, not PHP).
*/
public function test_get_daily_spend_sql_has_no_inline_comment(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( string $query ) use ( &$captured_sql ) {
$captured_sql = $query;
return 'prepared_query';
}
);
$this->wpdb->method( 'get_results' )->willReturn( array() );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_daily_spend( 30 );
$this->assertNotNull( $captured_sql, 'prepare() should have been called' );
$this->assertStringNotContainsString(
'// phpcs:ignore',
$captured_sql,
'get_daily_spend() SQL must not contain a PHP inline comment'
);
}
/**
* get_spend_by_stage() must not pass inline PHP comments to prepare().
*/
public function test_get_spend_by_stage_sql_has_no_inline_comment(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( string $query ) use ( &$captured_sql ) {
$captured_sql = $query;
return 'prepared_query';
}
);
$this->wpdb->method( 'get_results' )->willReturn( array() );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_spend_by_stage( '2026-06-01', '2026-06-30' );
$this->assertNotNull( $captured_sql, 'prepare() should have been called' );
$this->assertStringNotContainsString(
'// phpcs:ignore',
$captured_sql,
'get_spend_by_stage() SQL must not contain a PHP inline comment'
);
}
/**
* Confirm the existing monthly spend query was already clean (regression guard).
*/
public function test_get_monthly_spend_sql_was_already_clean(): void {
$captured_sql = null;
$this->wpdb->method( 'prepare' )->willReturnCallback(
function ( string $query ) use ( &$captured_sql ) {
$captured_sql = $query;
return 'prepared_query';
}
);
$this->wpdb->method( 'get_var' )->willReturn( '5.00' );
$reporter = new \PRAutoBlogger_Cost_Reporter();
$reporter->get_monthly_spend();
$this->assertNotNull( $captured_sql );
$this->assertStringNotContainsString(
'// phpcs:ignore',
$captured_sql,
'get_monthly_spend() SQL must not contain a PHP inline comment'
);
}
}