Compare commits
4 Commits
claude/pha
...
claude/pra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da1ccf394e | ||
|
|
5a62347bd1 | ||
|
|
1e77aff7c9 | ||
|
|
2bdc763dc0 |
2
.github/workflows/cto-review.yml
vendored
2
.github/workflows/cto-review.yml
vendored
@@ -451,7 +451,7 @@ jobs:
|
||||
if rev.get("concerns"):
|
||||
lines.append("**Concerns:**")
|
||||
for c in rev["concerns"]:
|
||||
sev = c.get("severity", "UNKNOWN").upper()
|
||||
sev = c["severity"].upper()
|
||||
f = f"`{c['file']}`" if c.get("file") else ""
|
||||
lines.append(f"- **{sev}** {f} — {c['issue']}")
|
||||
lines.append("")
|
||||
|
||||
@@ -119,19 +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
|
||||
@@ -139,9 +136,6 @@ 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 orchestrator: 4-column snapshot, delegates gen_log queries (v0.19.0)
|
||||
│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (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
|
||||
@@ -219,8 +213,6 @@ prautoblogger/
|
||||
│ │ ├── 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/
|
||||
@@ -244,7 +236,6 @@ 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
|
||||
@@ -346,19 +337,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -553,7 +536,7 @@ dropped on uninstall.
|
||||
| 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. |
|
||||
| agent_role | VARCHAR(50) | Fan-out dimension for Phase-2 quorum ('' default) |
|
||||
| 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 |
|
||||
@@ -614,8 +597,6 @@ 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
|
||||
|
||||
@@ -850,31 +831,14 @@ multi-step publish paths. Four subsystems:
|
||||
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.
|
||||
as **"incomplete"** in audit queries. Quorum logic itself is Phase 2 — the schema just
|
||||
reserves its dimensions.
|
||||
|
||||
### #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.
|
||||
|
||||
|
||||
|
||||
### #22b: Kanban board as primary admin landing screen (v0.19.0)
|
||||
|
||||
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` orchestrates the four columns and handles WP_Query columns
|
||||
(In Review, Published). Raw `prab_generation_log` queries (Generating, Failed) are in the
|
||||
extracted `class-board-gen-log-query.php` (split for 300-line compliance) -- no new schema
|
||||
required for M1. The data provider uses the `prautoblogger_generation_status` transient
|
||||
(written by the Executor for manual/daily runs) as the primary active-run signal, with a
|
||||
secondary query on recent `prab_generation_log` run_ids that have no linked post as a
|
||||
fallback. M2 will rename Runs & Audit -> Articles and wire board cards to the Article Dossier.
|
||||
Poll interval and published window are settings-backed, localized into board.js, never
|
||||
hardcoded.
|
||||
|
||||
## Cross-System LLM Budget Coordination
|
||||
|
||||
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.
|
||||
|
||||
185
CHANGELOG.md
185
CHANGELOG.md
@@ -5,40 +5,154 @@ 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.19.0] - 2026-06-12
|
||||
## [0.18.1] - 2026-06-11
|
||||
|
||||
### Added
|
||||
- **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).
|
||||
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
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## [0.17.0] - 2026-06-11
|
||||
|
||||
@@ -954,7 +1068,6 @@ swap with no user-visible change in article quality.
|
||||
run_id-based log linking, RuntimeException on WP_Error, action/filter
|
||||
hook firing. Replaces previous method-existence-only checks.
|
||||
|
||||
|
||||
## [0.2.1] — 2026-04-12
|
||||
|
||||
### Changed
|
||||
@@ -991,6 +1104,14 @@ swap with no user-visible change in article quality.
|
||||
- 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
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
/**
|
||||
* 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 ) );
|
||||
3
includes/admin/class-admin-page.php
Normal file → Executable file
3
includes/admin/class-admin-page.php
Normal file → Executable file
@@ -62,7 +62,6 @@ 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',
|
||||
@@ -324,7 +323,7 @@ class PRAutoBlogger_Admin_Page {
|
||||
return wp_json_encode( array_values( array_map( 'sanitize_text_field', $items ) ) );
|
||||
}
|
||||
|
||||
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_board_poll_interval', 'prautoblogger_board_published_window_days' );
|
||||
$numeric = array( 'prautoblogger_daily_article_target', 'prautoblogger_monthly_budget_usd', 'prautoblogger_min_word_count', 'prautoblogger_max_word_count', 'prautoblogger_default_author', 'prautoblogger_default_category', 'prautoblogger_pullpush_cache_ttl', 'prautoblogger_reddit_posts_per_subreddit', 'prautoblogger_reasoning_max_tokens' );
|
||||
if ( in_array( $option_name, $numeric, true ) ) {
|
||||
return is_numeric( $value ) ? $value : 0;
|
||||
}
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
<?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.
|
||||
*
|
||||
* Orchestrates the four board columns:
|
||||
* Generating | In Review | Published | Failed
|
||||
*
|
||||
* Generation-log queries (Generating, Failed) are delegated to
|
||||
* PRAutoBlogger_Board_Gen_Log_Query. WP_Query columns (In Review, Published)
|
||||
* are handled here.
|
||||
*
|
||||
* No schema changes -- all data comes from tables that already exist.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Board_Page::on_ajax_board_status().
|
||||
* Dependencies: PRAutoBlogger_Board_Gen_Log_Query, WordPress WP_Query, get_option().
|
||||
*
|
||||
* @see admin/class-board-gen-log-query.php -- Raw generation-log queries (Generating/Failed).
|
||||
* @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 {
|
||||
|
||||
/** @var PRAutoBlogger_Board_Gen_Log_Query Handles raw gen_log DB queries. */
|
||||
private PRAutoBlogger_Board_Gen_Log_Query $gen_log_query;
|
||||
|
||||
/**
|
||||
* @param PRAutoBlogger_Board_Gen_Log_Query|null $gen_log_query Optional injection for testing.
|
||||
*/
|
||||
public function __construct( ?PRAutoBlogger_Board_Gen_Log_Query $gen_log_query = null ) {
|
||||
$this->gen_log_query = $gen_log_query ?? new PRAutoBlogger_Board_Gen_Log_Query();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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->gen_log_query->get_generating_cards();
|
||||
$in_review = $this->get_in_review_cards();
|
||||
$published = $this->get_published_cards();
|
||||
$failed = $this->gen_log_query->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.
|
||||
* Delegates to PRAutoBlogger_Board_Gen_Log_Query.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function get_generating_cards(): array {
|
||||
return $this->gen_log_query->get_generating_cards();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cards for generation runs that ended in an error state.
|
||||
* Delegates to PRAutoBlogger_Board_Gen_Log_Query.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function get_failed_cards(): array {
|
||||
return $this->gen_log_query->get_failed_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;
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Raw generation-log queries for the kanban board.
|
||||
*
|
||||
* Extracted from PRAutoBlogger_Board_Data_Provider to stay under the 300-line
|
||||
* cap. Handles the two columns that read directly from prab_generation_log:
|
||||
* Generating (in-progress runs) | Failed (error runs with no linked post)
|
||||
*
|
||||
* No schema changes — data comes from prab_generation_log which already exists.
|
||||
*
|
||||
* Triggered by: PRAutoBlogger_Board_Data_Provider::get_generating_cards()
|
||||
* PRAutoBlogger_Board_Data_Provider::get_failed_cards()
|
||||
* Dependencies: WordPress $wpdb, get_transient().
|
||||
*
|
||||
* @see admin/class-board-data-provider.php — Orchestrator that calls this class.
|
||||
* @see ARCHITECTURE.md — Database schema (generation_log).
|
||||
*/
|
||||
class PRAutoBlogger_Board_Gen_Log_Query {
|
||||
|
||||
/** Seconds of gen-log inactivity after which a run is no longer "generating". */
|
||||
public const GENERATING_GRACE_SECONDS = 1800;
|
||||
|
||||
/** Transient key written by PRAutoBlogger_Executor for the active generation run. */
|
||||
public const STATUS_TRANSIENT = 'prautoblogger_generation_status';
|
||||
|
||||
/**
|
||||
* 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 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;
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
<?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 );
|
||||
}
|
||||
}
|
||||
@@ -78,29 +78,17 @@ 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.', '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' ),
|
||||
'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' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'prautoblogger_board_published_window_days',
|
||||
'label' => __( 'Published Column Window (days)', 'prautoblogger' ),
|
||||
'id' => 'prautoblogger_reasoning_max_tokens',
|
||||
'label' => __( 'Reasoning Token Cap', 'prautoblogger' ),
|
||||
'type' => 'number',
|
||||
'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' ),
|
||||
'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' ),
|
||||
),
|
||||
|
||||
// ── Schedule & Budget ───────────────────────────────────────
|
||||
@@ -135,6 +123,26 @@ 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(
|
||||
|
||||
@@ -4,26 +4,25 @@ declare(strict_types=1);
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Cron handlers for content generation and metrics collection.
|
||||
* Cron/AJAX handlers for content generation, metrics collection, model registry.
|
||||
*
|
||||
* What: Daily generation, manual generation, queued-article processing,
|
||||
* and metrics collection cron handlers. AJAX generation handlers
|
||||
* live in PRAutoBlogger_Generation_Status_Poller (v0.18.3 split).
|
||||
* Triggered by: PRAutoBlogger hook registration.
|
||||
* Dependencies: Pipeline_Runner, Metrics_Collector, Generation_Lock, Logger.
|
||||
*
|
||||
* @see class-generation-status-poller.php — "Generate Now" AJAX + status poll.
|
||||
* @see class-prautoblogger.php — Hook wiring.
|
||||
* @see core/class-pipeline-runner.php — Actual generation pipeline.
|
||||
* @see class-generation-lock.php — DB mutex.
|
||||
* @see class-prautoblogger.php — Hook wiring.
|
||||
* @see core/class-pipeline-runner.php — Actual generation pipeline.
|
||||
* @see class-generation-lock.php — DB mutex.
|
||||
*/
|
||||
class PRAutoBlogger_Executor {
|
||||
|
||||
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
|
||||
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
|
||||
|
||||
/** @var PRAutoBlogger_Generation_Status_Poller|null Lazy-loaded poller. */
|
||||
private ?PRAutoBlogger_Generation_Status_Poller $poller = null;
|
||||
/** Transient key for background generation status. */
|
||||
private const STATUS_TRANSIENT = 'prautoblogger_generation_status';
|
||||
|
||||
/** How long to keep the result available for polling (seconds). */
|
||||
private const STATUS_TTL = 600;
|
||||
|
||||
/**
|
||||
* Handle the daily generation cron event.
|
||||
@@ -59,6 +58,20 @@ class PRAutoBlogger_Executor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this process's run failed after a fatal pipeline error
|
||||
* (no-op outside a run; a governor-halted run stays halted — final
|
||||
* states are sticky).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function mark_current_run_failed(): void {
|
||||
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||
if ( null !== $run_id ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the metrics collection cron event.
|
||||
* Side effects: API calls to GA4, database writes to ab_content_scores.
|
||||
@@ -76,12 +89,79 @@ class PRAutoBlogger_Executor {
|
||||
|
||||
/**
|
||||
* AJAX handler: kick off manual generation as a background cron job.
|
||||
* Delegates to PRAutoBlogger_Generation_Status_Poller.
|
||||
*
|
||||
* Returns immediately so the browser never hits Hostinger's 120-second
|
||||
* connection timeout. The frontend polls on_ajax_generation_status()
|
||||
* every few seconds to get progress and final results.
|
||||
*
|
||||
* Side effects: schedules a WP-Cron event, writes a status transient.
|
||||
*/
|
||||
public function on_ajax_generate_now(): void {
|
||||
$this->poller()->on_ajax_generate_now();
|
||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
|
||||
if ( $force ) {
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_transient( self::STATUS_TRANSIENT );
|
||||
}
|
||||
|
||||
// Check if a run is already in progress.
|
||||
$current = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'background' => true,
|
||||
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Write initial "running" status for the frontend to poll.
|
||||
set_transient(
|
||||
self::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation...', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
'last_updated' => time(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
);
|
||||
|
||||
// Schedule immediate one-shot cron event. WordPress will fire this
|
||||
// on the next page load (or via wp-cron.php) in a separate PHP process
|
||||
// that is not subject to the 120-second connection timeout.
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_manual_generation' ) ) {
|
||||
wp_schedule_single_event( time(), 'prautoblogger_manual_generation' );
|
||||
}
|
||||
|
||||
// Spawn the cron immediately via a non-blocking loopback request
|
||||
// so we don't depend on the next visitor to trigger it.
|
||||
spawn_cron();
|
||||
|
||||
// Direct non-blocking loopback to wp-cron.php — ensures the event
|
||||
// fires even if DISABLE_WP_CRON is true or spawn_cron() no-ops.
|
||||
wp_remote_post(
|
||||
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||
array(
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'background' => true,
|
||||
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,18 +179,18 @@ class PRAutoBlogger_Executor {
|
||||
|
||||
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
self::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
self::STATUS_TTL
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->poller()->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
|
||||
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
|
||||
|
||||
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
|
||||
$results = $runner->run();
|
||||
@@ -119,7 +199,7 @@ class PRAutoBlogger_Executor {
|
||||
// When articles ARE queued, the pipeline handles status + lock release.
|
||||
if ( ! get_option( 'prautoblogger_article_queue' ) ) {
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
self::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'complete',
|
||||
'generated' => $results['generated'],
|
||||
@@ -127,7 +207,7 @@ class PRAutoBlogger_Executor {
|
||||
'rejected' => $results['rejected'],
|
||||
'cost' => $results['cost'],
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
self::STATUS_TTL
|
||||
);
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
}
|
||||
@@ -137,12 +217,12 @@ class PRAutoBlogger_Executor {
|
||||
'pipeline'
|
||||
);
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
self::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
self::STATUS_TTL
|
||||
);
|
||||
$this->mark_current_run_failed();
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
@@ -167,44 +247,89 @@ class PRAutoBlogger_Executor {
|
||||
delete_option( 'prautoblogger_article_queue' );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
set_transient(
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
|
||||
self::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $e->getMessage(),
|
||||
),
|
||||
PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
|
||||
self::STATUS_TTL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** AJAX: return generation status for frontend polling. Delegates to poller. */
|
||||
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
|
||||
public function on_ajax_generation_status(): void {
|
||||
$this->poller()->on_ajax_generation_status();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load the generation status poller.
|
||||
*
|
||||
* @return PRAutoBlogger_Generation_Status_Poller
|
||||
*/
|
||||
private function poller(): PRAutoBlogger_Generation_Status_Poller {
|
||||
if ( null === $this->poller ) {
|
||||
$this->poller = new PRAutoBlogger_Generation_Status_Poller();
|
||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
return;
|
||||
}
|
||||
return $this->poller;
|
||||
|
||||
$status = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( ! is_array( $status ) ) {
|
||||
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'running' === $status['status'] ) {
|
||||
$elapsed = time() - ( $status['started'] ?? 0 );
|
||||
|
||||
// Absolute timeout — give up after 10 minutes.
|
||||
if ( $elapsed > self::STATUS_TTL ) {
|
||||
$this->abort_orphaned_run( __( 'Generation timed out. Check the Activity Log.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// After 90s, check if a queued job died and needs re-scheduling.
|
||||
if ( $elapsed > 90 && get_option( 'prautoblogger_article_queue' ) ) {
|
||||
if ( ! wp_next_scheduled( PRAutoBlogger_Pipeline_Runner::CRON_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), PRAutoBlogger_Pipeline_Runner::CRON_ACTION );
|
||||
spawn_cron();
|
||||
}
|
||||
}
|
||||
|
||||
// Stall = 5 min since last stage update (not since start).
|
||||
$last_activity = $status['last_updated'] ?? $status['started'] ?? 0;
|
||||
$idle_seconds = time() - $last_activity;
|
||||
if ( $idle_seconds > 300 ) {
|
||||
$this->abort_orphaned_run( __( 'Generation stalled. Check Activity Log.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
wp_send_json_success( $status );
|
||||
}
|
||||
|
||||
/** Clean up an orphaned generation run and report error. */
|
||||
private function abort_orphaned_run( string $message ): void {
|
||||
// v0.18.0: the dead run renders as failed/"incomplete" in audit
|
||||
// queries instead of silently lingering as 'running'.
|
||||
$queue = get_option( 'prautoblogger_article_queue' );
|
||||
if ( is_array( $queue ) && ! empty( $queue['run_id'] ) ) {
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' );
|
||||
}
|
||||
delete_transient( self::STATUS_TRANSIENT );
|
||||
delete_option( 'prautoblogger_article_queue' );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark this process's run failed after a fatal pipeline error
|
||||
* (no-op outside a run; a governor-halted run stays halted — final
|
||||
* states are sticky).
|
||||
* Helper: update the generation stage for frontend polling.
|
||||
*
|
||||
* @return void
|
||||
* @param string $stage Human-readable stage description.
|
||||
*/
|
||||
private function mark_current_run_failed(): void {
|
||||
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||
if ( null !== $run_id ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
private function update_generation_stage( string $stage ): void {
|
||||
$current = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( is_array( $current ) ) {
|
||||
$current['stage'] = $stage;
|
||||
$current['last_updated'] = time();
|
||||
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,29 +76,6 @@ class PRAutoBlogger_Generation_Lock {
|
||||
return null !== $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Unix timestamp when the lock was acquired, or null if not held.
|
||||
*
|
||||
* Returns null when the lock is not set. The timestamp is the value written
|
||||
* by acquire() — used by the status poller to detect infrastructure-timeout
|
||||
* runs (lock held longer than STATUS_TTL → background PHP process died).
|
||||
*
|
||||
* @return int|null Unix timestamp, or null when the lock is absent.
|
||||
*/
|
||||
public static function get_acquired_at(): ?int {
|
||||
global $wpdb;
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$row = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
|
||||
self::LOCK_NAME
|
||||
)
|
||||
);
|
||||
|
||||
return null !== $row ? (int) $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the generation mutex.
|
||||
*
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* AJAX handlers for "Generate Now" + status polling.
|
||||
*
|
||||
* What: Two AJAX endpoints: kick-off (fire-and-forget cron spawn) and the
|
||||
* status poll that drives the admin UI spinner. Extracted from
|
||||
* PRAutoBlogger_Executor in v0.18.3 to keep each file under 300 lines.
|
||||
*
|
||||
* R2/R3 resilience (v0.18.3):
|
||||
* - R2(a): on_ajax_generate_now() renews the transient with the
|
||||
* full STATUS_TTL on every stage update via update_generation_stage().
|
||||
* - R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
|
||||
* when the lock has been held > STATUS_TTL seconds (Hostinger background-
|
||||
* process death); marks the stuck run failed + surfaces a human-readable
|
||||
* "infrastructure timeout" message for the Activity Log.
|
||||
* - R3: on_ajax_generation_status() returns status:running (with
|
||||
* started_at) when the transient is absent but the lock IS held and
|
||||
* within TTL — so the button reflects reality during long runs rather
|
||||
* than silently resetting to idle.
|
||||
*
|
||||
* Who calls it: PRAutoBlogger hook registration (wp_ajax_prautoblogger_*).
|
||||
* Dependencies: PRAutoBlogger_Generation_Lock, PRAutoBlogger_Run_State,
|
||||
* PRAutoBlogger_Run_Context, PRAutoBlogger_Pipeline_Runner,
|
||||
* PRAutoBlogger_Logger.
|
||||
*
|
||||
* @see class-executor.php — Cron handlers (daily, manual, queued).
|
||||
* @see class-generation-lock.php — DB mutex (acquire/release/timestamps).
|
||||
* @see core/class-pipeline-status.php — Transient broadcast helpers.
|
||||
* @see ARCHITECTURE.md #21 — Run lifecycle and reaper design.
|
||||
*/
|
||||
class PRAutoBlogger_Generation_Status_Poller {
|
||||
|
||||
/** Transient key for background generation status (shared with Pipeline_Status). */
|
||||
public const STATUS_TRANSIENT = 'prautoblogger_generation_status';
|
||||
|
||||
/**
|
||||
* How long (seconds) to keep the status transient readable.
|
||||
* Renewed on every stage transition so a long run doesn't expire it early.
|
||||
*/
|
||||
public const STATUS_TTL = 600;
|
||||
|
||||
/**
|
||||
* AJAX handler: kick off manual generation as a background cron job.
|
||||
*
|
||||
* Returns immediately so the browser never hits Hostinger's connection
|
||||
* timeout. The frontend polls on_ajax_generation_status() every few
|
||||
* seconds for progress and final results.
|
||||
*
|
||||
* Side effects: schedules a WP-Cron event, writes a status transient.
|
||||
*/
|
||||
public function on_ajax_generate_now(): void {
|
||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$force = isset( $_POST['force'] ) && '1' === $_POST['force'];
|
||||
if ( $force ) {
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
delete_transient( self::STATUS_TRANSIENT );
|
||||
}
|
||||
|
||||
// Check if a run is already in progress.
|
||||
$current = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( is_array( $current ) && 'running' === ( $current['status'] ?? '' ) ) {
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'background' => true,
|
||||
'message' => __( 'Generation already in progress.', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Write initial "running" status for the frontend to poll.
|
||||
set_transient(
|
||||
self::STATUS_TRANSIENT,
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Starting generation...', 'prautoblogger' ),
|
||||
'started' => time(),
|
||||
'last_updated' => time(),
|
||||
),
|
||||
self::STATUS_TTL
|
||||
);
|
||||
|
||||
// Schedule immediate one-shot cron event. WordPress will fire this
|
||||
// on the next page load (or via wp-cron.php) in a separate PHP process
|
||||
// not subject to the connection timeout.
|
||||
if ( ! wp_next_scheduled( 'prautoblogger_manual_generation' ) ) {
|
||||
wp_schedule_single_event( time(), 'prautoblogger_manual_generation' );
|
||||
}
|
||||
|
||||
// Spawn the cron immediately via a non-blocking loopback request
|
||||
// so we don't depend on the next visitor to trigger it.
|
||||
spawn_cron();
|
||||
|
||||
// Direct non-blocking loopback to wp-cron.php — ensures the event
|
||||
// fires even if DISABLE_WP_CRON is true or spawn_cron() no-ops.
|
||||
wp_remote_post(
|
||||
site_url( 'wp-cron.php?doing_wp_cron=' . sprintf( '%.22F', microtime( true ) ) ),
|
||||
array(
|
||||
'timeout' => 0.01,
|
||||
'blocking' => false,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'background' => true,
|
||||
'message' => __( 'Generation started in background. Polling for status...', 'prautoblogger' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */
|
||||
public function on_ajax_generation_status(): void {
|
||||
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' );
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
|
||||
return;
|
||||
}
|
||||
|
||||
$status = get_transient( self::STATUS_TRANSIENT );
|
||||
|
||||
// R2(b) + R3: transient is gone — check the lock before returning idle.
|
||||
if ( ! is_array( $status ) ) {
|
||||
$this->handle_missing_transient();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'running' === $status['status'] ) {
|
||||
$elapsed = time() - ( $status['started'] ?? 0 );
|
||||
|
||||
// Absolute timeout — give up after STATUS_TTL.
|
||||
if ( $elapsed > self::STATUS_TTL ) {
|
||||
$this->abort_orphaned_run( __( 'Generation timed out. Check the Activity Log.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// After 90s, check if a queued job died and needs re-scheduling.
|
||||
if ( $elapsed > 90 && get_option( 'prautoblogger_article_queue' ) ) {
|
||||
if ( ! wp_next_scheduled( PRAutoBlogger_Pipeline_Runner::CRON_ACTION ) ) {
|
||||
wp_schedule_single_event( time(), PRAutoBlogger_Pipeline_Runner::CRON_ACTION );
|
||||
spawn_cron();
|
||||
}
|
||||
}
|
||||
|
||||
// Stall = 5 min since last stage update (not since start).
|
||||
$last_activity = $status['last_updated'] ?? $status['started'] ?? 0;
|
||||
$idle_seconds = time() - $last_activity;
|
||||
if ( $idle_seconds > 300 ) {
|
||||
$this->abort_orphaned_run( __( 'Generation stalled. Check Activity Log.', 'prautoblogger' ) );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
wp_send_json_success( $status );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the case where the status transient is absent.
|
||||
*
|
||||
* R3: if lock is HELD and within TTL → return status:running so the
|
||||
* button reflects reality during long background runs.
|
||||
* R2(b): if lock is held but has been held > STATUS_TTL → the background
|
||||
* PHP process died without releasing it; mark the run failed and
|
||||
* surface "infrastructure timeout" to the UI.
|
||||
*
|
||||
* @return void Sends JSON and exits.
|
||||
*/
|
||||
private function handle_missing_transient(): void {
|
||||
$acquired_at = PRAutoBlogger_Generation_Lock::get_acquired_at();
|
||||
|
||||
if ( null === $acquired_at ) {
|
||||
// Lock not held — genuinely idle.
|
||||
wp_send_json_success( array( 'status' => 'idle' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$lock_age = time() - $acquired_at;
|
||||
|
||||
if ( $lock_age > self::STATUS_TTL ) {
|
||||
// Lock held longer than STATUS_TTL: background process died.
|
||||
// R2(b): mark the stuck run failed + surface a clear error.
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
sprintf(
|
||||
'Status transient absent and lock held for %ds (> %ds TTL) — background process died; marking run failed.',
|
||||
$lock_age,
|
||||
self::STATUS_TTL
|
||||
),
|
||||
'pipeline'
|
||||
);
|
||||
$this->abort_orphaned_run(
|
||||
__( 'Generation failed — infrastructure timeout. Check Activity Log for details.', 'prautoblogger' )
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// R3: lock is fresh (within TTL) but transient is gone — long run in
|
||||
// progress (pipeline broadcast hasn't fired yet, or transient was
|
||||
// evicted by object-cache pressure). Return running so the button
|
||||
// does not silently reset to idle.
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'running',
|
||||
'stage' => __( 'Generation in progress (background). Check Activity Log for details.', 'prautoblogger' ),
|
||||
'started' => $acquired_at,
|
||||
'last_updated' => $acquired_at,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Clean up an orphaned generation run and report error. */
|
||||
private function abort_orphaned_run( string $message ): void {
|
||||
// Mark the stuck run as failed in the audit ledger.
|
||||
$queue = get_option( 'prautoblogger_article_queue' );
|
||||
if ( is_array( $queue ) && ! empty( $queue['run_id'] ) ) {
|
||||
PRAutoBlogger_Run_State::mark_status( (string) $queue['run_id'], 'failed' );
|
||||
}
|
||||
// Also attempt to close the current active run from Run_Context.
|
||||
$run_id = PRAutoBlogger_Run_Context::current_run_id();
|
||||
if ( null !== $run_id ) {
|
||||
PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' );
|
||||
}
|
||||
delete_transient( self::STATUS_TRANSIENT );
|
||||
delete_option( 'prautoblogger_article_queue' );
|
||||
PRAutoBlogger_Generation_Lock::release();
|
||||
wp_send_json_success(
|
||||
array(
|
||||
'status' => 'error',
|
||||
'message' => $message,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the generation stage text for frontend polling.
|
||||
* Renews the transient TTL at every call so a long run doesn't expire it.
|
||||
*
|
||||
* @param string $stage Human-readable stage description.
|
||||
*/
|
||||
public function update_generation_stage( string $stage ): void {
|
||||
$current = get_transient( self::STATUS_TRANSIENT );
|
||||
if ( is_array( $current ) ) {
|
||||
$current['stage'] = $stage;
|
||||
$current['last_updated'] = time();
|
||||
// R2(a): renew the TTL on every stage update so the transient
|
||||
// outlives any single LLM call (STATUS_TTL from last activity).
|
||||
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL );
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,19 @@ declare(strict_types=1);
|
||||
* Main orchestrator for the PRAutoBlogger plugin.
|
||||
*
|
||||
* Registers all WordPress hooks (actions and filters) and wires up dependencies.
|
||||
* This is the ONLY place hooks are registered -- individual classes contain only
|
||||
* This is the ONLY place hooks are registered — individual classes contain only
|
||||
* business logic, not hook registration.
|
||||
*
|
||||
* Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`.
|
||||
* Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor.
|
||||
*
|
||||
* @see prautoblogger.php -- Plugin bootstrap that instantiates this class.
|
||||
* @see class-executor.php -- Cron/AJAX handlers for generation, model registry.
|
||||
* @see class-ajax-handlers.php -- Non-generation AJAX endpoints (images, models, test).
|
||||
* @see class-generation-lock.php -- DB mutex for single-writer generation.
|
||||
* @see core/class-pipeline-runner.php -- Executes the generation pipeline.
|
||||
* @see ARCHITECTURE.md -- Data flow diagram showing how classes interact.
|
||||
* @see CONVENTIONS.md -- Hook naming conventions.
|
||||
* @see prautoblogger.php — Plugin bootstrap that instantiates this class.
|
||||
* @see class-executor.php — Cron/AJAX handlers for generation, model registry.
|
||||
* @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test).
|
||||
* @see class-generation-lock.php — DB mutex for single-writer generation.
|
||||
* @see core/class-pipeline-runner.php — Executes the generation pipeline.
|
||||
* @see ARCHITECTURE.md — Data flow diagram showing how classes interact.
|
||||
* @see CONVENTIONS.md — Hook naming conventions.
|
||||
*/
|
||||
class PRAutoBlogger {
|
||||
|
||||
@@ -46,7 +46,7 @@ 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
|
||||
// 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' ) );
|
||||
@@ -67,11 +67,6 @@ 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' ) );
|
||||
@@ -91,7 +86,7 @@ class PRAutoBlogger {
|
||||
|
||||
( new PRAutoBlogger_Post_List_Columns() )->register();
|
||||
|
||||
// Block false update notifications -- our slug collides with another plugin.
|
||||
// Block false update notifications — our slug collides with another plugin.
|
||||
add_filter( 'site_transient_update_plugins', array( $this, 'filter_block_false_updates' ) );
|
||||
}
|
||||
|
||||
@@ -116,7 +111,7 @@ class PRAutoBlogger {
|
||||
// Hostinger's 120-second web-server connection timeout.
|
||||
add_action( 'prautoblogger_manual_generation', array( $this->executor, 'on_manual_generation' ) );
|
||||
|
||||
// Chained article generation -- each queued article fires as its own
|
||||
// Chained article generation — each queued article fires as its own
|
||||
// cron event so it gets a fresh PHP process and execution time budget.
|
||||
add_action(
|
||||
PRAutoBlogger_Pipeline_Runner::CRON_ACTION,
|
||||
@@ -202,12 +197,6 @@ 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' ) );
|
||||
@@ -291,7 +280,7 @@ class PRAutoBlogger {
|
||||
// value into a deprecated-keyed option for one version cycle (so a
|
||||
// customised value is recoverable / auditable) and seed the new
|
||||
// template with its editorial default. The old option is intentionally
|
||||
// NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md paragraph 8 Commit 1.
|
||||
// NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md §8 Commit 1.
|
||||
if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) {
|
||||
$old_suffix = get_option( 'prautoblogger_image_style_suffix', '' );
|
||||
if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) {
|
||||
@@ -304,7 +293,7 @@ class PRAutoBlogger {
|
||||
update_option( 'prautoblogger_migrated_style_template_v0160', '1' );
|
||||
}
|
||||
|
||||
// v0.9.0 -- Runware as default image model. v0.10.0 -- remove CF Workers AI.
|
||||
// v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI.
|
||||
PRAutoBlogger_Activator::migrate_default_image_v090();
|
||||
PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run();
|
||||
|
||||
@@ -322,11 +311,9 @@ class PRAutoBlogger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run schema migrations during cron requests.
|
||||
*
|
||||
* Wraps on_check_db_version() so it only fires during WP-Cron execution.
|
||||
* After a deploy, the daily generation cron can fire before any admin page
|
||||
* load -- without this, new-column inserts would silently fail.
|
||||
* 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().
|
||||
*
|
||||
@@ -376,7 +363,7 @@ class PRAutoBlogger {
|
||||
return $this->executor;
|
||||
}
|
||||
|
||||
// -- Backward-compatible proxies --
|
||||
// ── Backward-compatible proxies ──
|
||||
|
||||
/** @see PRAutoBlogger_Executor::on_daily_generation() */
|
||||
public function on_daily_generation(): void {
|
||||
|
||||
@@ -76,9 +76,9 @@ class PRAutoBlogger_Analysis_Prompts {
|
||||
global $wpdb;
|
||||
$scores_table = $wpdb->prefix . 'prautoblogger_content_scores';
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$top_posts = $wpdb->get_results(
|
||||
"SELECT cs.post_id, cs.composite_score, p.post_title
|
||||
"SELECT cs.post_id, cs.composite_score, p.post_title // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
FROM {$scores_table} cs
|
||||
JOIN {$wpdb->posts} p ON p.ID = cs.post_id
|
||||
WHERE cs.composite_score > 0
|
||||
|
||||
@@ -103,7 +103,7 @@ class PRAutoBlogger_Article_Worker {
|
||||
$review = $this->review_with_state( $editor, $content, $idea, $run_id, $item );
|
||||
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Saving and publishing…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'publish' ), $item );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', '', $item );
|
||||
$this->publish_or_draft(
|
||||
$content,
|
||||
$idea,
|
||||
@@ -174,7 +174,7 @@ class PRAutoBlogger_Article_Worker {
|
||||
return new PRAutoBlogger_Editorial_Review( $data );
|
||||
}
|
||||
}
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'review' ), $item );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', '', $item );
|
||||
}
|
||||
|
||||
$review = $editor->review( $content, $idea );
|
||||
|
||||
@@ -194,7 +194,7 @@ class PRAutoBlogger_Content_Generator {
|
||||
);
|
||||
return $cached;
|
||||
}
|
||||
PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ), $this->item_key );
|
||||
PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, '', $this->item_key );
|
||||
}
|
||||
|
||||
// v0.18.1: stage metadata for the provider's empty-completion
|
||||
|
||||
@@ -36,9 +36,8 @@ class PRAutoBlogger_Cost_Reporter {
|
||||
$first_of_month = gmdate( 'Y-m-01 00:00:00' );
|
||||
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
$result = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT COALESCE(SUM(estimated_cost), 0) FROM {$table} WHERE created_at >= %s AND response_status = 'success'",
|
||||
$first_of_month
|
||||
)
|
||||
@@ -65,10 +64,9 @@ 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(
|
||||
$wpdb->prepare(
|
||||
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
|
||||
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
FROM {$table}
|
||||
WHERE created_at >= %s AND response_status = 'success'
|
||||
GROUP BY DATE(created_at)
|
||||
@@ -103,13 +101,12 @@ 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(
|
||||
$wpdb->prepare(
|
||||
"SELECT stage,
|
||||
SUM(estimated_cost) as total_cost,
|
||||
SUM(prompt_tokens + completion_tokens) as total_tokens,
|
||||
COUNT(*) as call_count
|
||||
COUNT(*) as call_count // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
FROM {$table}
|
||||
WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
|
||||
GROUP BY stage",
|
||||
@@ -143,7 +140,6 @@ 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.
|
||||
*
|
||||
|
||||
@@ -185,14 +185,14 @@ class PRAutoBlogger_Pipeline_Runner {
|
||||
$source_labels = is_array( $enabled ) ? implode( ', ', $enabled ) : 'reddit';
|
||||
/* translators: %s is a comma-separated list of enabled source names, e.g. "reddit, llm_research". */
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'research' ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research' );
|
||||
( new PRAutoBlogger_Source_Collector() )
|
||||
->set_cost_tracker( $cost_tracker )
|
||||
->collect_from_all_sources();
|
||||
PRAutoBlogger_Run_Stage_State::done( $run_id, 'research' );
|
||||
|
||||
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'analysis' ) );
|
||||
PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis' );
|
||||
$llm = new PRAutoBlogger_OpenRouter_Provider();
|
||||
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
|
||||
$analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) );
|
||||
|
||||
@@ -108,8 +108,8 @@ class PRAutoBlogger_Post_Assembler {
|
||||
// Find the unlinked research cost row for this run.
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
|
||||
$research_row = $wpdb->get_row(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
|
||||
$wpdb->prepare(
|
||||
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
||||
FROM {$table}
|
||||
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
|
||||
LIMIT 1",
|
||||
|
||||
@@ -6,33 +6,40 @@ declare(strict_types=1);
|
||||
*
|
||||
* Per-run per-stage state machine over `wp_prautoblogger_run_stages`.
|
||||
*
|
||||
* One row per (run_id, stage, agent_role, item_key) holding
|
||||
* pending|running|done|failed|halted plus the output snapshot (meta_json).
|
||||
* Done stages are sticky and never re-charged on resume. Self-healing:
|
||||
* a missing table degrades every method to a no-op/false.
|
||||
* What: One row per (run_id, stage, agent_role, item_key) holding
|
||||
* pending|running|done|failed|halted plus the stage's output
|
||||
* snapshot (meta_json). This is what makes resume idempotent on the
|
||||
* chained-cron architecture: a `done` stage is NEVER re-run or
|
||||
* re-charged — re-entry reuses its stored output; `done` is sticky
|
||||
* (an upsert cannot demote it). agent_role is the Phase-2 fan-out
|
||||
* dimension (quorum members; the quorum logic itself is Phase 2);
|
||||
* item_key scopes article-level stages because one run_id spans all
|
||||
* N articles of a batch run ('' for run-level stages). Self-healing:
|
||||
* a missing table degrades every method to a no-op/false.
|
||||
* Who triggers it: Pipeline_Runner (run-level research/analysis),
|
||||
* Article_Worker (per-article generate/review/publish),
|
||||
* Content_Generator (per-LLM-stage outline/draft/polish),
|
||||
* Run_Reaper (stuck-stage sweep + payload pruning).
|
||||
* Dependencies: WordPress $wpdb, wp_json_encode.
|
||||
*
|
||||
* v0.18.2: role is resolved from Stage_Display_Map when caller passes ''
|
||||
* so start/done/is_done/get_output are always symmetric. Explicit non-empty
|
||||
* roles (Phase-2 fan-out) are honoured as-is. get() falls back to role=''
|
||||
* on miss for mid-run-upgrade compat with v0.18.1 rows.
|
||||
*
|
||||
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
|
||||
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
|
||||
* @see ARCHITECTURE.md #22 — Pipeline v2 Phase 1 substrate.
|
||||
* @see core/class-run-state.php — Run-level lifecycle + ledger row.
|
||||
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'.
|
||||
* @see ARCHITECTURE.md #21 — Idempotency-key design.
|
||||
*/
|
||||
class PRAutoBlogger_Run_Stage_State {
|
||||
|
||||
/** @var bool|null Per-request "run_stages table exists" probe result. */
|
||||
private static ?bool $table_ok = null;
|
||||
|
||||
/** @return string Fully-qualified run_stages table name. */
|
||||
/** Fully-qualified run_stages table name. */
|
||||
public static function table_name(): string {
|
||||
global $wpdb;
|
||||
return $wpdb->prefix . 'prautoblogger_run_stages';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the run_stages table exists and is queryable. Cached per request.
|
||||
* Whether the run_stages table exists and is queryable. Cached per
|
||||
* request; never throws.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
@@ -42,7 +49,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
global $wpdb;
|
||||
if ( null === $wpdb ) {
|
||||
return false;
|
||||
return false; // Not cached: $wpdb may appear later in boot.
|
||||
}
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
@@ -52,17 +59,18 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable 16-hex hash for one article idea within a run.
|
||||
* Stable hash identifying one article idea within a run (post-creation
|
||||
* idempotency + stage item scoping both key off this).
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea.
|
||||
* @return string
|
||||
* @return string 16-hex-char hash.
|
||||
*/
|
||||
public static function idea_hash( PRAutoBlogger_Article_Idea $idea ): string {
|
||||
return substr( md5( $idea->get_suggested_title() . '|' . $idea->get_topic() ), 0, 16 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage item_key for an article idea ('' reserved for run-level stages).
|
||||
* Stage item_key for an article idea ('' is reserved for run-level stages).
|
||||
*
|
||||
* @param PRAutoBlogger_Article_Idea $idea The idea.
|
||||
* @return string e.g. 'idea:1a2b3c4d5e6f7a8b'.
|
||||
@@ -72,18 +80,22 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage as running. Sticky: done stages are never demoted.
|
||||
* Mark a stage as entered (running). Upsert: first entry inserts the
|
||||
* row; re-entry bumps `attempt` — but a `done` stage is sticky and is
|
||||
* neither demoted nor re-counted.
|
||||
*
|
||||
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name (see Stage_Display_Map).
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $agent_role Fan-out dimension ('' default in Phase 1).
|
||||
* @param string $item_key Article scope ('' for run-level stages).
|
||||
* @return void
|
||||
*/
|
||||
public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
@@ -108,20 +120,22 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage done and persist its output for resume-without-recharge.
|
||||
* Mark a stage done, persisting its output for resume-without-recharge.
|
||||
*
|
||||
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $item_key Article scope.
|
||||
* @param string|null $output Stage output snapshot (pruned after R days).
|
||||
* @param float $cost_usd Cost attributed to this stage.
|
||||
* @param string|null $output Stage output to snapshot (pruned after R days).
|
||||
* @param float $cost_usd Cost attributed to the stage.
|
||||
* @return void
|
||||
*/
|
||||
public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
@@ -152,18 +166,18 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a stage failed (does not demote a done stage).
|
||||
* Mark a stage failed (does not demote a `done` stage).
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $item_key Article scope.
|
||||
* @return void
|
||||
*/
|
||||
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
$now = current_time( 'mysql' );
|
||||
@@ -183,20 +197,18 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one stage row. Falls back to role='' on miss for mid-run-upgrade
|
||||
* compat with v0.18.1 rows (one extra indexed query, only on miss).
|
||||
* Fetch one stage row.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $item_key Article scope.
|
||||
* @return array<string, mixed>|null
|
||||
* @return array<string, mixed>|null Row or null.
|
||||
*/
|
||||
public static function get( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): ?array {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
return null;
|
||||
}
|
||||
$agent_role = self::resolve_role( $stage, $agent_role );
|
||||
global $wpdb;
|
||||
$table = self::table_name();
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
@@ -210,25 +222,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
if ( is_array( $row ) ) {
|
||||
return $row;
|
||||
}
|
||||
// v0.18.1 upgrade compat: rows started before this fix carry role=''.
|
||||
// One extra indexed query, only on miss, only for known stages.
|
||||
if ( '' !== $agent_role ) {
|
||||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
|
||||
$legacy = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE run_id = %s AND stage = %s AND agent_role = '' AND item_key = %s",
|
||||
$run_id,
|
||||
$stage,
|
||||
$item_key
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
return is_array( $legacy ) ? $legacy : null;
|
||||
}
|
||||
return null;
|
||||
return is_array( $row ) ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,7 +230,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $item_key Article scope.
|
||||
* @return bool
|
||||
*/
|
||||
@@ -250,7 +244,7 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Fan-out dimension ('' = resolve from stage map).
|
||||
* @param string $agent_role Fan-out dimension.
|
||||
* @param string $item_key Article scope.
|
||||
* @return string|null
|
||||
*/
|
||||
@@ -264,10 +258,13 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail every still-open stage of one item. Done stages are untouched.
|
||||
* Fail every still-open (pending/running) stage of one item — used by
|
||||
* the worker's catch-all so a crashed article leaves no stage stuck
|
||||
* in 'running' until the reaper. Done stages are untouched.
|
||||
*
|
||||
* @param string $run_id Run UUID.
|
||||
* @param string $item_key Stage item key ('' = run-level stages).
|
||||
* @return void
|
||||
*/
|
||||
public static function fail_open_for_item( string $run_id, string $item_key ): void {
|
||||
if ( '' === $run_id || ! self::is_available() ) {
|
||||
@@ -293,21 +290,4 @@ class PRAutoBlogger_Run_Stage_State {
|
||||
public static function flush_cache(): void {
|
||||
self::$table_ok = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective agent role for a stage.
|
||||
*
|
||||
* '' → derive from Stage_Display_Map (unknown stages map to '').
|
||||
* Non-empty → returned as-is (Phase-2 explicit fan-out role).
|
||||
*
|
||||
* @param string $stage Stage name.
|
||||
* @param string $agent_role Caller-supplied role ('' = auto-resolve).
|
||||
* @return string Effective agent role.
|
||||
*/
|
||||
private static function resolve_role( string $stage, string $agent_role ): string {
|
||||
if ( '' !== $agent_role ) {
|
||||
return $agent_role;
|
||||
}
|
||||
return (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,33 +76,8 @@ class PRAutoBlogger_Source_Collector {
|
||||
'collector'
|
||||
);
|
||||
} catch ( \Throwable $e ) {
|
||||
// Structured failure meta: model slug, HTTP status (parsed from message),
|
||||
// and first ~200 chars of the error body — enables post-incident diagnosis
|
||||
// without needing to re-run the failing collection. Added v0.18.2.
|
||||
$config_for_log = $this->get_config_for_source( $source_type );
|
||||
$model_slug = (string) ( $config_for_log['model'] ?? '' );
|
||||
$err_msg = $e->getMessage();
|
||||
// Extract HTTP status from "OpenRouter API error (HTTP NNN): ..." pattern.
|
||||
$http_status = 0;
|
||||
if ( preg_match( '/HTTP (\d{3})/', $err_msg, $m ) ) {
|
||||
$http_status = (int) $m[1];
|
||||
}
|
||||
PRAutoBlogger_Logger::instance()->error(
|
||||
sprintf(
|
||||
'Collection %s from %s: %s',
|
||||
get_class( $e ),
|
||||
$source_type,
|
||||
$e->getMessage()
|
||||
),
|
||||
'collector',
|
||||
array(
|
||||
'source' => $source_type,
|
||||
'model' => $model_slug,
|
||||
'http_status' => $http_status,
|
||||
'error_body' => mb_substr( $err_msg, 0, 200 ),
|
||||
)
|
||||
);
|
||||
// Continue with other sources — one failure should not stop everything.
|
||||
PRAutoBlogger_Logger::instance()->error( sprintf( 'Collection %s from %s: %s', get_class( $e ), $source_type, $e->getMessage() ), 'collector' );
|
||||
// Continue with other sources — one failure shouldn't stop everything.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Runware modelSearch API fetcher — handles pagination and response normalization
|
||||
* for the Runware model catalog sync.
|
||||
*
|
||||
* Extracted from PRAutoBlogger_Runware_Model_Catalog for the 300-line rule.
|
||||
* Provides two public methods consumed by the catalog class:
|
||||
* - fetch_all_text_to_image(): paginates modelSearch and returns raw results.
|
||||
* - normalize_models(): converts raw results to Image_Model_Registry shape.
|
||||
*
|
||||
* API note (2026-06-12): Runware retired taskType:models. The replacement is
|
||||
* taskType:modelSearch — requires auth+modelSearch tasks, returns paginated
|
||||
* results with `air` identifiers and `capabilities[]`. Text-to-image filtering
|
||||
* is client-side (no server-side capability filter is available). The catalog
|
||||
* has 320k+ entries (mostly community checkpoints); we cap at PAGE_CAP pages
|
||||
* of PAGE_SIZE results to bound request cost while covering curated t2i models.
|
||||
*
|
||||
* @see class-runware-model-catalog.php — Caller; owns caching, cooldown, and fallback.
|
||||
* @see class-image-model-registry.php — Consumes the normalized model shape.
|
||||
*/
|
||||
class PRAutoBlogger_Runware_Catalog_Fetcher {
|
||||
|
||||
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
|
||||
private const HTTP_TIMEOUT_SEC = 30;
|
||||
|
||||
/**
|
||||
* Maximum pages to fetch per sync cycle.
|
||||
* 5 pages × 100 results = 500 models inspected.
|
||||
* Curated Runware-native text-to-image models number ~50 in the first 500.
|
||||
*/
|
||||
private const PAGE_CAP = 5;
|
||||
private const PAGE_SIZE = 100;
|
||||
|
||||
/**
|
||||
* Fetch all text-to-image model results via paginated modelSearch calls.
|
||||
*
|
||||
* Issues up to PAGE_CAP requests against the Runware /v1 endpoint, each
|
||||
* sending an auth task + a modelSearch task with offset/limit. Stops early
|
||||
* when all available results have been fetched. Returns the raw result
|
||||
* objects (unfiltered — normalize_models() applies the capability filter).
|
||||
*
|
||||
* @param string $api_key Decrypted Runware API key.
|
||||
* @return array<int, array<string, mixed>> Raw modelSearch result objects.
|
||||
* @throws \RuntimeException On HTTP error or malformed response for any page.
|
||||
*/
|
||||
public function fetch_all_text_to_image( string $api_key ): array {
|
||||
$all_results = array();
|
||||
$offset = 0;
|
||||
$pages_fetched = 0;
|
||||
$total = PHP_INT_MAX; // Set after first page.
|
||||
|
||||
do {
|
||||
$page = $this->fetch_page( $api_key, $offset, self::PAGE_SIZE );
|
||||
if ( empty( $page['results'] ) ) {
|
||||
break;
|
||||
}
|
||||
|
||||
$all_results = array_merge( $all_results, $page['results'] );
|
||||
$total = (int) $page['totalResults'];
|
||||
$offset += self::PAGE_SIZE;
|
||||
++$pages_fetched;
|
||||
|
||||
if ( count( $all_results ) >= $total ) {
|
||||
break;
|
||||
}
|
||||
} while ( $pages_fetched < self::PAGE_CAP );
|
||||
|
||||
if ( $pages_fetched >= self::PAGE_CAP && count( $all_results ) < $total ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf(
|
||||
'Runware catalog: reached page cap (%d pages, %d models fetched of %d total). '
|
||||
. 'Increase PAGE_CAP if curated models are being missed.',
|
||||
self::PAGE_CAP,
|
||||
count( $all_results ),
|
||||
$total
|
||||
),
|
||||
'runware-catalog'
|
||||
);
|
||||
}
|
||||
|
||||
return $all_results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize raw modelSearch result objects to the Image_Model_Registry shape.
|
||||
*
|
||||
* Filters to models with "io:text-to-image" in capabilities[].
|
||||
* Uses the `air` field as the model id (e.g. "runware:100@1").
|
||||
* Uses the `comment` field as the human-readable description.
|
||||
* cost_per_image is always null here — merged by the catalog class.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $raw_models Raw modelSearch results.
|
||||
* @return array<int, array<string, mixed>> Normalized models.
|
||||
*/
|
||||
public function normalize_models( array $raw_models ): array {
|
||||
$normalized = array();
|
||||
|
||||
foreach ( $raw_models as $raw ) {
|
||||
if ( ! is_array( $raw ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$capabilities = is_array( $raw['capabilities'] ?? null ) ? $raw['capabilities'] : array();
|
||||
if ( ! in_array( 'io:text-to-image', $capabilities, true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$air = (string) ( $raw['air'] ?? '' );
|
||||
if ( '' === $air ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = array(
|
||||
'id' => $air,
|
||||
'name' => (string) ( $raw['name'] ?? $air ),
|
||||
'provider' => 'runware',
|
||||
'cost_per_image' => null,
|
||||
'capabilities' => array( 'image_generation' ),
|
||||
'description' => (string) ( $raw['comment'] ?? '' ),
|
||||
);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single page of modelSearch results.
|
||||
*
|
||||
* Sends [authentication, modelSearch] task pair to /v1.
|
||||
* A fresh auth task is required per request per Runware's WebSocket-based
|
||||
* API contract (each HTTP POST is a stateless task array).
|
||||
*
|
||||
* @param string $api_key Decrypted Runware API key.
|
||||
* @param int $offset Pagination offset (0-based).
|
||||
* @param int $limit Page size.
|
||||
* @return array{results: array<int, array<string, mixed>>, totalResults: int}
|
||||
* @throws \RuntimeException On HTTP error or malformed response.
|
||||
*/
|
||||
private function fetch_page( string $api_key, int $offset, int $limit ): array {
|
||||
$payload = array(
|
||||
array(
|
||||
'taskType' => 'authentication',
|
||||
'apiKey' => $api_key,
|
||||
),
|
||||
array(
|
||||
'taskType' => 'modelSearch',
|
||||
'taskUUID' => wp_generate_uuid4(),
|
||||
'offset' => $offset,
|
||||
'limit' => $limit,
|
||||
),
|
||||
);
|
||||
|
||||
$response = wp_remote_post(
|
||||
self::RUNWARE_API_URL,
|
||||
array(
|
||||
'timeout' => self::HTTP_TIMEOUT_SEC,
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => wp_json_encode( $payload ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf( 'Runware modelSearch unreachable: %s', $response->get_error_message() )
|
||||
);
|
||||
}
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $status ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf( 'Runware modelSearch returned HTTP %d', $status )
|
||||
);
|
||||
}
|
||||
|
||||
$raw = (string) wp_remote_retrieve_body( $response );
|
||||
$decoded = json_decode( $raw, true );
|
||||
|
||||
if ( ! is_array( $decoded ) ) {
|
||||
throw new \RuntimeException( 'Runware modelSearch response is not valid JSON.' );
|
||||
}
|
||||
|
||||
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||
$first_msg = '';
|
||||
if ( ! empty( $decoded['errors'][0]['message'] ) ) {
|
||||
$first_msg = ': ' . (string) $decoded['errors'][0]['message'];
|
||||
}
|
||||
throw new \RuntimeException( 'Runware modelSearch API error' . $first_msg );
|
||||
}
|
||||
|
||||
$data = $decoded['data'] ?? array();
|
||||
if ( ! is_array( $data ) || empty( $data ) ) {
|
||||
return array(
|
||||
'results' => array(),
|
||||
'totalResults' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
$item = $data[0];
|
||||
$results = is_array( $item['results'] ?? null ) ? $item['results'] : array();
|
||||
$total = isset( $item['totalResults'] ) ? (int) $item['totalResults'] : 0;
|
||||
|
||||
return array(
|
||||
'results' => $results,
|
||||
'totalResults' => $total,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -181,8 +181,7 @@ class PRAutoBlogger_Runware_Image_Support {
|
||||
*/
|
||||
public function normalize_seed( ?int $seed ): int {
|
||||
if ( null === $seed || $seed < 1 ) {
|
||||
// Cast to int before modulo — float % int is deprecated in PHP 8 (v0.18.2).
|
||||
$derived = (int) round( microtime( true ) * 1000 ) % 2147483647;
|
||||
$derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 );
|
||||
return $derived > 0 ? $derived : 1;
|
||||
}
|
||||
return $seed;
|
||||
|
||||
@@ -4,30 +4,18 @@ declare(strict_types=1);
|
||||
/**
|
||||
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
|
||||
*
|
||||
* Runware model catalog sync — orchestrates periodic fetching, caching, failure
|
||||
* cooldown, and fallback for the Runware model list.
|
||||
* Runware model catalog sync — fetches live model list via Runware's /v1 endpoint,
|
||||
* filters to text-to-image tasks, caches normalized results, and falls back to
|
||||
* hardcoded list on API failure.
|
||||
*
|
||||
* What: Syncs Runware's model catalog (daily cron + on-demand AJAX), caches
|
||||
* normalized results in WP options (v2 schema key — see below), merges
|
||||
* pricing, and enforces a per-failure cooldown to stop admin-page-load
|
||||
* cascades when the API is broken or the key is misconfigured.
|
||||
* What: Syncs Runware's model catalog periodically (daily cron + on-demand AJAX),
|
||||
* caches the results in WP options, and merges pricing data from the pricing
|
||||
* class when Runware's API doesn't expose it.
|
||||
* Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now"
|
||||
* button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry.
|
||||
* Dependencies: PRAutoBlogger_Runware_Catalog_Fetcher, PRAutoBlogger_Logger,
|
||||
* PRAutoBlogger_Runware_Image_Pricing, PRAutoBlogger_Image_Model_Registry.
|
||||
* Dependencies: PRAutoBlogger_Logger, PRAutoBlogger_Runware_Image_Pricing,
|
||||
* PRAutoBlogger_Image_Model_Registry.
|
||||
*
|
||||
* Cache schema note: option key is prautoblogger_runware_model_cache_v2 (bumped
|
||||
* from v1) to cleanly invalidate cached rows that used the old taskType:models
|
||||
* response shape (taskType/requiresImage fields). The v1 option is left in place
|
||||
* and will be purged by the next WP options cleanup pass.
|
||||
*
|
||||
* Failure cooldown: prautoblogger_runware_catalog_last_failure_at records the
|
||||
* last failure timestamp. is_in_failure_cooldown() checks it against
|
||||
* prautoblogger_runware_catalog_failure_cooldown_seconds (default 3600s). This
|
||||
* caps errors from admin page loads to ~24/day (24h / 1h cooldown) rather than
|
||||
* 40+/day from every Settings/picker page load triggering a live sync.
|
||||
*
|
||||
* @see class-runware-catalog-fetcher.php — Pagination + normalize (extracted for 300-line rule).
|
||||
* @see class-runware-image-pricing.php — Authoritative pricing source & fallback models.
|
||||
* @see admin/class-image-model-registry.php — Caller; merges Runware + OpenRouter lists.
|
||||
* @see class-prautoblogger.php — Registers daily cron + AJAX hook.
|
||||
@@ -35,27 +23,20 @@ declare(strict_types=1);
|
||||
*/
|
||||
class PRAutoBlogger_Runware_Model_Catalog {
|
||||
|
||||
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
|
||||
private const CACHE_TTL_SECONDS = 86400;
|
||||
private const HTTP_TIMEOUT_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* Option key for the v2 normalized model cache (air-identifier schema).
|
||||
* Bumped from v1 (prautoblogger_runware_model_cache) to force re-sync.
|
||||
*/
|
||||
private const CACHE_OPTION = 'prautoblogger_runware_model_cache_v2';
|
||||
private const CACHE_TS_OPTION = 'prautoblogger_runware_model_cache_updated_at';
|
||||
private const FAILURE_TS_OPTION = 'prautoblogger_runware_catalog_last_failure_at';
|
||||
private const COOLDOWN_SETTING = 'prautoblogger_runware_catalog_failure_cooldown_seconds';
|
||||
private const COOLDOWN_DEFAULT = 3600;
|
||||
|
||||
/**
|
||||
* Fetch, normalize, merge pricing, and cache the Runware model catalog.
|
||||
* On success: writes cache + clears failure timestamp. Returns true.
|
||||
* On failure: writes failure timestamp for cooldown. Returns false.
|
||||
* Fetch the Runware model catalog, normalize to the Image_Model_Registry
|
||||
* shape, and write to WP options cache. Returns true on success, false on
|
||||
* API failure or parse error.
|
||||
*
|
||||
* Side effects: makes HTTP request to Runware API, writes WP options.
|
||||
* Error handling: logs via PRAutoBlogger_Logger, never throws, always
|
||||
* returns bool to allow fallback to cached/hardcoded list.
|
||||
* returns bool to allow fallback to cached list.
|
||||
*
|
||||
* @return bool True on successful fetch and cache update; false otherwise.
|
||||
* @return bool True on successful fetch and cache update; false on API or parse error.
|
||||
*/
|
||||
public function sync(): bool {
|
||||
try {
|
||||
@@ -68,19 +49,16 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fetcher = new PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||
$raw_models = $fetcher->fetch_all_text_to_image( $api_key );
|
||||
|
||||
$raw_models = $this->fetch_models_from_api( $api_key );
|
||||
if ( empty( $raw_models ) ) {
|
||||
PRAutoBlogger_Logger::instance()->warning(
|
||||
'Runware model catalog fetch returned empty list. Using cached/fallback models.',
|
||||
'runware-catalog'
|
||||
);
|
||||
$this->record_failure();
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = $fetcher->normalize_models( $raw_models );
|
||||
$normalized = $this->normalize_models( $raw_models );
|
||||
$with_pricing = $this->merge_pricing( $normalized );
|
||||
|
||||
if ( empty( $with_pricing ) ) {
|
||||
@@ -88,13 +66,11 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
||||
'Runware model catalog normalization resulted in empty list. Using cached/fallback models.',
|
||||
'runware-catalog'
|
||||
);
|
||||
$this->record_failure();
|
||||
return false;
|
||||
}
|
||||
|
||||
update_option( self::CACHE_OPTION, $with_pricing, false );
|
||||
update_option( self::CACHE_TS_OPTION, time(), false );
|
||||
delete_option( self::FAILURE_TS_OPTION );
|
||||
update_option( 'prautoblogger_runware_model_cache', $with_pricing, false );
|
||||
update_option( 'prautoblogger_runware_model_cache_updated_at', time(), false );
|
||||
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ),
|
||||
@@ -106,37 +82,38 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
||||
sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
|
||||
'runware-catalog'
|
||||
);
|
||||
$this->record_failure();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the normalized model list, using smart cache + fallback logic:
|
||||
* fresh cache → cached; stale + not in cooldown → sync; stale cache fallback;
|
||||
* no cache → hardcoded fallback (never empty).
|
||||
* Get the normalized model list, with smart caching logic:
|
||||
* - If cache is fresh (< 24h), return it.
|
||||
* - If cache is stale or absent, trigger a sync.
|
||||
* - If sync fails, return the last-known-good cache.
|
||||
* - If no cache exists, return the hardcoded fallback (never empty).
|
||||
*
|
||||
* @return array<int, array<string, mixed>> Normalized model list.
|
||||
*/
|
||||
public function get_models(): array {
|
||||
if ( ! $this->is_stale() ) {
|
||||
$cached = get_option( self::CACHE_OPTION, array() );
|
||||
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
|
||||
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $this->is_in_failure_cooldown() && $this->sync() ) {
|
||||
$cached = get_option( self::CACHE_OPTION, array() );
|
||||
if ( $this->sync() ) {
|
||||
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
|
||||
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$cached = get_option( self::CACHE_OPTION, array() );
|
||||
$cached = get_option( 'prautoblogger_runware_model_cache', array() );
|
||||
if ( is_array( $cached ) && ! empty( $cached ) ) {
|
||||
PRAutoBlogger_Logger::instance()->info(
|
||||
'Using stale Runware model cache (live sync failed or suppressed by cooldown).',
|
||||
'Using stale Runware model cache (live sync failed).',
|
||||
'runware-catalog'
|
||||
);
|
||||
return $cached;
|
||||
@@ -154,12 +131,12 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix timestamp of the last successful sync, or null if never synced.
|
||||
* Get the Unix timestamp of the last successful sync, or null if never synced.
|
||||
*
|
||||
* @return int|null
|
||||
* @return int|null Unix timestamp, or null if never synced.
|
||||
*/
|
||||
public function get_last_synced_at(): ?int {
|
||||
$ts = get_option( self::CACHE_TS_OPTION, null );
|
||||
$ts = get_option( 'prautoblogger_runware_model_cache_updated_at', null );
|
||||
if ( null === $ts ) {
|
||||
return null;
|
||||
}
|
||||
@@ -168,23 +145,9 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unix timestamp of the last sync failure, or null if no failure on record.
|
||||
* Check if the cache is stale (older than 24h) or absent.
|
||||
*
|
||||
* @return int|null
|
||||
*/
|
||||
public function get_last_failure_at(): ?int {
|
||||
$ts = get_option( self::FAILURE_TS_OPTION, null );
|
||||
if ( null === $ts ) {
|
||||
return null;
|
||||
}
|
||||
$ts = (int) $ts;
|
||||
return $ts > 0 ? $ts : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cache is stale (older than 24h) or absent.
|
||||
*
|
||||
* @return bool True if a refresh is warranted; false if still fresh.
|
||||
* @return bool True if cache should be refreshed; false if still fresh.
|
||||
*/
|
||||
public function is_stale(): bool {
|
||||
$updated_at = $this->get_last_synced_at();
|
||||
@@ -195,51 +158,123 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a sync attempt should be suppressed due to a recent failure.
|
||||
* Fetch the raw model list from Runware's /v1 endpoint via a models task.
|
||||
*
|
||||
* The cooldown window is read from prautoblogger_runware_catalog_failure_cooldown_seconds
|
||||
* (default 3600s). This prevents admin-page-load cascades (40+ errors/day)
|
||||
* when the API endpoint or key is broken. A successful sync clears it.
|
||||
*
|
||||
* @return bool True if a sync should be suppressed; false if retry is permitted.
|
||||
* @param string $api_key Decrypted Runware API key.
|
||||
* @return array Raw model list (may be empty).
|
||||
* @throws \RuntimeException On HTTP error or malformed response.
|
||||
*/
|
||||
public function is_in_failure_cooldown(): bool {
|
||||
$last_failure = $this->get_last_failure_at();
|
||||
if ( null === $last_failure ) {
|
||||
return false;
|
||||
private function fetch_models_from_api( string $api_key ): array {
|
||||
$body = wp_json_encode(
|
||||
array(
|
||||
array(
|
||||
'taskType' => 'models',
|
||||
'apiKey' => $api_key,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$response = wp_remote_post(
|
||||
self::RUNWARE_API_URL,
|
||||
array(
|
||||
'timeout' => self::HTTP_TIMEOUT_SECONDS,
|
||||
'headers' => array( 'Content-Type' => 'application/json' ),
|
||||
'body' => $body,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
throw new \RuntimeException(
|
||||
sprintf( 'Runware models endpoint unreachable: %s', $response->get_error_message() )
|
||||
);
|
||||
}
|
||||
$cooldown = (int) get_option( self::COOLDOWN_SETTING, self::COOLDOWN_DEFAULT );
|
||||
if ( $cooldown <= 0 ) {
|
||||
$cooldown = self::COOLDOWN_DEFAULT;
|
||||
|
||||
$status = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $status ) {
|
||||
$raw = wp_remote_retrieve_body( $response );
|
||||
throw new \RuntimeException(
|
||||
sprintf( 'Runware models endpoint returned HTTP %d', $status )
|
||||
);
|
||||
}
|
||||
return ( time() - $last_failure ) < $cooldown;
|
||||
|
||||
$raw = (string) wp_remote_retrieve_body( $response );
|
||||
$decoded = json_decode( $raw, true );
|
||||
|
||||
if ( ! is_array( $decoded ) ) {
|
||||
throw new \RuntimeException( 'Runware models response is not valid JSON.' );
|
||||
}
|
||||
|
||||
if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) && ! empty( $decoded['errors'] ) ) {
|
||||
throw new \RuntimeException( 'Runware API returned an error.' );
|
||||
}
|
||||
|
||||
$data = $decoded['data'] ?? array();
|
||||
return is_array( $data ) ? $data : array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge pricing from PRAutoBlogger_Runware_Image_Pricing into normalized models.
|
||||
* Models without a pricing entry get cost_per_image=null.
|
||||
* Normalize raw Runware model objects to the PRAutoBlogger_Image_Model_Registry
|
||||
* shape: id, name, provider, cost_per_image, capabilities, description.
|
||||
*
|
||||
* Filters to taskType='imageInference' (text-to-image only), excluding
|
||||
* inpainting (imageInference + requiresImage=true), img2img, video, upscalers.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $raw_models Raw API response.
|
||||
* @return array<int, array<string, mixed>> Normalized models.
|
||||
*/
|
||||
private function normalize_models( array $raw_models ): array {
|
||||
$normalized = array();
|
||||
|
||||
foreach ( $raw_models as $raw ) {
|
||||
if ( ! is_array( $raw ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$task_type = $raw['taskType'] ?? '';
|
||||
if ( 'imageInference' !== $task_type ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( isset( $raw['requiresImage'] ) && true === $raw['requiresImage'] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$model_id = (string) ( $raw['id'] ?? '' );
|
||||
if ( '' === $model_id ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = array(
|
||||
'id' => $model_id,
|
||||
'name' => (string) ( $raw['name'] ?? $model_id ),
|
||||
'provider' => 'runware',
|
||||
'cost_per_image' => null,
|
||||
'capabilities' => array( 'image_generation' ),
|
||||
'description' => (string) ( $raw['description'] ?? '' ),
|
||||
);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge pricing data from PRAutoBlogger_Runware_Image_Pricing into the
|
||||
* normalized catalog. Models without pricing data get cost_per_image=null.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $normalized Normalized models.
|
||||
* @return array<int, array<string, mixed>> Models with pricing merged.
|
||||
*/
|
||||
private function merge_pricing( array $normalized ): array {
|
||||
$pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
|
||||
|
||||
foreach ( $normalized as &$model ) {
|
||||
$model_id = $model['id'] ?? '';
|
||||
if ( isset( $pricing_table[ $model_id ] ) ) {
|
||||
$model['cost_per_image'] = (float) $pricing_table[ $model_id ];
|
||||
}
|
||||
}
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the current time as the last sync failure timestamp.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function record_failure(): void {
|
||||
update_option( self::FAILURE_TS_OPTION, time(), false );
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,10 +287,12 @@ class PRAutoBlogger_Runware_Model_Catalog {
|
||||
if ( '' === $stored ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
|
||||
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
|
||||
return '' === $decrypted ? '' : $decrypted;
|
||||
}
|
||||
|
||||
return $stored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.19.0
|
||||
* Version: 0.18.1
|
||||
* Requires at least: 6.0
|
||||
* Requires PHP: 7.4
|
||||
* Author: PeptideRepo
|
||||
@@ -35,14 +35,26 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
| Defined here so every file in the plugin can reference paths, versions,
|
||||
| and limits without magic strings.
|
||||
*/
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.19.0' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
|
||||
define( 'PRAUTOBLOGGER_VERSION', '0.18.1' );
|
||||
define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.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.
|
||||
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 );
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
<?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 -->
|
||||
@@ -103,17 +103,12 @@ if ( ! defined( 'OBJECT' ) ) {
|
||||
/**
|
||||
* Minimal WP_Query stub for unit tests.
|
||||
*
|
||||
* IdeaScorer uses WP_Query to check for existing posts with empty results.
|
||||
* Board data provider tests inject posts via WP_Query::$_test_posts_queue
|
||||
* (static FIFO): push arrays of WP_Post objects before instantiating the
|
||||
* provider, and each new WP_Query() call will dequeue one batch.
|
||||
* IdeaScorer uses WP_Query to check for existing posts.
|
||||
* This stub returns empty results by default.
|
||||
*/
|
||||
if ( ! class_exists( 'WP_Query' ) ) {
|
||||
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
|
||||
class WP_Query {
|
||||
/** @var array<int, array> FIFO queue of post-arrays for tests. */
|
||||
public static $_test_posts_queue = [];
|
||||
|
||||
/** @var array */
|
||||
public $posts = [];
|
||||
/** @var int */
|
||||
@@ -122,11 +117,7 @@ if ( ! class_exists( 'WP_Query' ) ) {
|
||||
public $have_posts = false;
|
||||
|
||||
public function __construct( $args = [] ) {
|
||||
if ( ! empty( self::$_test_posts_queue ) ) {
|
||||
$this->posts = array_shift( self::$_test_posts_queue );
|
||||
$this->found_posts = count( $this->posts );
|
||||
$this->have_posts = $this->found_posts > 0;
|
||||
}
|
||||
// No-op for testing — returns no posts.
|
||||
}
|
||||
|
||||
public function have_posts(): bool {
|
||||
@@ -174,34 +165,3 @@ if ( ! class_exists( 'WP_Error' ) ) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal WP_Post stub for unit tests.
|
||||
*
|
||||
* Board data provider tests return WP_Post instances from WP_Query.
|
||||
* Class defined here so instanceof checks in production code work correctly.
|
||||
*/
|
||||
if ( ! class_exists( 'WP_Post' ) ) {
|
||||
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound
|
||||
class WP_Post {
|
||||
/** @var int */
|
||||
public $ID = 0;
|
||||
/** @var string */
|
||||
public $post_title = '';
|
||||
/** @var string */
|
||||
public $post_status = 'publish';
|
||||
/** @var string */
|
||||
public $post_date_gmt = '';
|
||||
/** @var string */
|
||||
public $post_modified_gmt = '';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function __construct( array $data = [] ) {
|
||||
foreach ( $data as $key => $value ) {
|
||||
$this->$key = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
131
tests/fixtures/runware-model-search-page1.json
vendored
131
tests/fixtures/runware-model-search-page1.json
vendored
@@ -1,131 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"taskType": "modelSearch",
|
||||
"taskUUID": "aabbccdd-0000-0000-0000-000000000001",
|
||||
"totalResults": 5,
|
||||
"results": [
|
||||
{
|
||||
"air": "runware:100@1",
|
||||
"name": "FLUX.1 schnell",
|
||||
"nameContent": "FLUX.1 schnell",
|
||||
"comment": "Fast 4-step text-to-image model",
|
||||
"category": "checkpoint",
|
||||
"private": false,
|
||||
"capabilities": ["io:text-to-image", "form:checkpoint"],
|
||||
"tags": [],
|
||||
"heroImage": "",
|
||||
"version": "",
|
||||
"architecture": "",
|
||||
"nsfwLevel": null,
|
||||
"downloadCount": 0,
|
||||
"inferenceCount24h": 0,
|
||||
"rating": 0,
|
||||
"ratingCount": 0,
|
||||
"thumbsUpCount": 0,
|
||||
"thumbsDownCount": 0,
|
||||
"featured": 1,
|
||||
"addedUnixTimestamp": 0,
|
||||
"updatedDateUnixTimestamp": 0,
|
||||
"source": "curated"
|
||||
},
|
||||
{
|
||||
"air": "runware:101@1",
|
||||
"name": "FLUX.1 dev",
|
||||
"nameContent": "FLUX.1 dev",
|
||||
"comment": "High fidelity 28-step text-to-image model",
|
||||
"category": "checkpoint",
|
||||
"private": false,
|
||||
"capabilities": ["io:text-to-image", "form:checkpoint"],
|
||||
"tags": [],
|
||||
"heroImage": "",
|
||||
"version": "",
|
||||
"architecture": "",
|
||||
"nsfwLevel": null,
|
||||
"downloadCount": 0,
|
||||
"inferenceCount24h": 0,
|
||||
"rating": 0,
|
||||
"ratingCount": 0,
|
||||
"thumbsUpCount": 0,
|
||||
"thumbsDownCount": 0,
|
||||
"featured": 0,
|
||||
"addedUnixTimestamp": 0,
|
||||
"updatedDateUnixTimestamp": 0,
|
||||
"source": "curated"
|
||||
},
|
||||
{
|
||||
"air": "some-text-model:llm@1",
|
||||
"name": "Some Text LLM",
|
||||
"nameContent": "Some Text LLM",
|
||||
"comment": "A text-to-text model that should be excluded",
|
||||
"category": "text",
|
||||
"private": false,
|
||||
"capabilities": ["io:text-to-text", "form:checkpoint"],
|
||||
"tags": [],
|
||||
"heroImage": "",
|
||||
"version": "",
|
||||
"architecture": "",
|
||||
"nsfwLevel": null,
|
||||
"downloadCount": 0,
|
||||
"inferenceCount24h": 0,
|
||||
"rating": 0,
|
||||
"ratingCount": 0,
|
||||
"thumbsUpCount": 0,
|
||||
"thumbsDownCount": 0,
|
||||
"featured": 0,
|
||||
"addedUnixTimestamp": 0,
|
||||
"updatedDateUnixTimestamp": 0,
|
||||
"source": "curated"
|
||||
},
|
||||
{
|
||||
"air": "runware:400@2",
|
||||
"name": "FLUX.2 klein 9B",
|
||||
"nameContent": "FLUX.2 klein 9B",
|
||||
"comment": "Compact 9B text-to-image model",
|
||||
"category": "checkpoint",
|
||||
"private": false,
|
||||
"capabilities": ["io:text-to-image", "form:checkpoint"],
|
||||
"tags": [],
|
||||
"heroImage": "",
|
||||
"version": "",
|
||||
"architecture": "",
|
||||
"nsfwLevel": null,
|
||||
"downloadCount": 0,
|
||||
"inferenceCount24h": 0,
|
||||
"rating": 0,
|
||||
"ratingCount": 0,
|
||||
"thumbsUpCount": 0,
|
||||
"thumbsDownCount": 0,
|
||||
"featured": 0,
|
||||
"addedUnixTimestamp": 0,
|
||||
"updatedDateUnixTimestamp": 0,
|
||||
"source": "curated"
|
||||
},
|
||||
{
|
||||
"air": "upscale-model:upscaler@1",
|
||||
"name": "Upscale Model",
|
||||
"nameContent": "Upscale Model",
|
||||
"comment": "Image upscaler — should be excluded from t2i list",
|
||||
"category": "checkpoint",
|
||||
"private": false,
|
||||
"capabilities": ["io:image-to-image", "op:upscale", "form:checkpoint"],
|
||||
"tags": [],
|
||||
"heroImage": "",
|
||||
"version": "",
|
||||
"architecture": "",
|
||||
"nsfwLevel": null,
|
||||
"downloadCount": 0,
|
||||
"inferenceCount24h": 0,
|
||||
"rating": 0,
|
||||
"ratingCount": 0,
|
||||
"thumbsUpCount": 0,
|
||||
"thumbsDownCount": 0,
|
||||
"featured": 0,
|
||||
"addedUnixTimestamp": 0,
|
||||
"updatedDateUnixTimestamp": 0,
|
||||
"source": "curated"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
<?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;
|
||||
|
||||
// Reset WP_Query test queue before each test.
|
||||
\WP_Query::$_test_posts_queue = [];
|
||||
|
||||
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 );
|
||||
|
||||
// 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 {
|
||||
\WP_Query::$_test_posts_queue = [];
|
||||
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 \WP_Post();
|
||||
$post->ID = 42;
|
||||
$post->post_title = 'Test Draft Post';
|
||||
$post->post_date_gmt = '2026-06-12 10:00:00';
|
||||
$post->post_status = 'draft';
|
||||
|
||||
// Inject post into WP_Query queue: first WP_Query() call returns this post.
|
||||
\WP_Query::$_test_posts_queue[] = 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 \WP_Post();
|
||||
$post->ID = 77;
|
||||
$post->post_title = 'Published Article';
|
||||
$post->post_date_gmt = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );
|
||||
$post->post_status = 'publish';
|
||||
|
||||
// Inject post into WP_Query queue: first WP_Query() call returns this post.
|
||||
\WP_Query::$_test_posts_queue[] = 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() );
|
||||
|
||||
$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() );
|
||||
|
||||
$provider = new \PRAutoBlogger_Board_Data_Provider();
|
||||
$snapshot = $provider->get_board_snapshot();
|
||||
|
||||
$this->assertFalse( $snapshot['has_active_runs'] );
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Analysis_Prompts.
|
||||
*
|
||||
* Regression + behavioral coverage for the GA4 self-improvement loop:
|
||||
* - SQL passed to $wpdb->get_results() must not embed phpcs:ignore comments
|
||||
* (MariaDB rejects the query if it does).
|
||||
* - get_performance_context() returns a non-empty, correctly-shaped string
|
||||
* when seeded content_scores rows are present.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class AnalysisPromptsTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
// Analysis prompts uses $wpdb->posts (JOIN {->posts}) and prefix.
|
||||
$this->wpdb->posts = 'wp_posts';
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: get_performance_context() SQL must not embed phpcs:ignore
|
||||
* inside the string literal — MariaDB rejects the query if it does,
|
||||
* causing get_performance_context() to silently return '' on every run.
|
||||
*
|
||||
* @see includes/core/class-analysis-prompts.php — fixed in v0.18.3
|
||||
*/
|
||||
public function test_get_performance_context_sql_has_no_inline_phpcs_ignore(): void {
|
||||
$captured_sql = null;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql ) use ( &$captured_sql ) {
|
||||
// get_results is called WITHOUT prepare (direct string interpolation
|
||||
// of a table prefix); capture the first arg to get_results instead.
|
||||
return $sql;
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_results' )->willReturnCallback(
|
||||
function ( $sql ) use ( &$captured_sql ) {
|
||||
$captured_sql = $sql;
|
||||
return array();
|
||||
}
|
||||
);
|
||||
|
||||
\PRAutoBlogger_Analysis_Prompts::get_performance_context();
|
||||
|
||||
$this->assertNotNull( $captured_sql, 'get_results() was not called' );
|
||||
$this->assertStringNotContainsString(
|
||||
'// phpcs:ignore',
|
||||
(string) $captured_sql,
|
||||
'SQL passed to get_results() must not contain a phpcs:ignore comment — MariaDB rejects it as a syntax error'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Behavioral: get_performance_context() returns a non-empty, correctly-shaped
|
||||
* string when the scores table contains rows with composite_score > 0.
|
||||
*
|
||||
* The returned string must:
|
||||
* - start with the "Top performing" header line
|
||||
* - contain at least one bullet entry of the form '- "…" (score: N.N)'
|
||||
* - not be empty
|
||||
*/
|
||||
public function test_get_performance_context_returns_shaped_context_with_seeded_rows(): void {
|
||||
// Simulate two content_scores rows returned from the DB query.
|
||||
$rows = array(
|
||||
array(
|
||||
'post_id' => 100,
|
||||
'composite_score' => '9.2',
|
||||
'post_title' => 'BPC-157 and Gut Healing — What the Research Shows',
|
||||
),
|
||||
array(
|
||||
'post_id' => 101,
|
||||
'composite_score' => '7.8',
|
||||
'post_title' => 'Semaglutide Dosing Guide for Weight Loss',
|
||||
),
|
||||
);
|
||||
|
||||
$this->wpdb->method( 'get_results' )->willReturn( $rows );
|
||||
|
||||
$context = \PRAutoBlogger_Analysis_Prompts::get_performance_context();
|
||||
|
||||
$this->assertNotEmpty( $context, 'get_performance_context() returned empty string with seeded rows' );
|
||||
$this->assertStringContainsString(
|
||||
'Top performing past articles',
|
||||
$context,
|
||||
'Context block must start with the header line'
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'BPC-157 and Gut Healing',
|
||||
$context,
|
||||
'Context block must contain the first seeded article title'
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'score: 9.2',
|
||||
$context,
|
||||
'Context block must include formatted composite_score'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge case: get_performance_context() returns '' when no rows exist.
|
||||
*/
|
||||
public function test_get_performance_context_returns_empty_string_when_no_rows(): void {
|
||||
$this->wpdb->method( 'get_results' )->willReturn( array() );
|
||||
|
||||
$context = \PRAutoBlogger_Analysis_Prompts::get_performance_context();
|
||||
|
||||
$this->assertSame( '', $context, 'get_performance_context() must return empty string when scores table is empty' );
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
<?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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -90,60 +90,4 @@ class CostReporterTest extends BaseTestCase {
|
||||
$this->assertIsFloat( $utilization );
|
||||
$this->assertGreaterThanOrEqual( 0.0, $utilization );
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: get_daily_spend SQL must not embed phpcs:ignore inside the
|
||||
* string literal (MariaDB would receive the comment as literal SQL text and
|
||||
* reject the query). Captures the first argument passed to prepare() and
|
||||
* asserts the SQL is clean.
|
||||
*
|
||||
* @see includes/core/class-cost-reporter.php — fixed in v0.18.2
|
||||
*/
|
||||
public function test_get_daily_spend_sql_contains_no_inline_phpcs_ignore(): void {
|
||||
$captured_sql = null;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql, ...$args ) use ( &$captured_sql ) {
|
||||
$captured_sql = $sql;
|
||||
return 'prepared';
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_results' )->willReturn( [] );
|
||||
|
||||
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||
$reporter->get_daily_spend( 7 );
|
||||
|
||||
$this->assertNotNull( $captured_sql, 'prepare() was not called' );
|
||||
$this->assertStringNotContainsString(
|
||||
'// phpcs:ignore',
|
||||
(string) $captured_sql,
|
||||
'SQL string must not contain phpcs:ignore comment — MariaDB rejects it'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression: get_spend_by_stage SQL must not embed phpcs:ignore inside
|
||||
* the string literal.
|
||||
*
|
||||
* @see includes/core/class-cost-reporter.php — fixed in v0.18.2
|
||||
*/
|
||||
public function test_get_spend_by_stage_sql_contains_no_inline_phpcs_ignore(): void {
|
||||
$captured_sql = null;
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql, ...$args ) use ( &$captured_sql ) {
|
||||
$captured_sql = $sql;
|
||||
return 'prepared';
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_results' )->willReturn( [] );
|
||||
|
||||
$reporter = new \PRAutoBlogger_Cost_Reporter();
|
||||
$reporter->get_spend_by_stage( '2026-06-01', '2026-06-30' );
|
||||
|
||||
$this->assertNotNull( $captured_sql, 'prepare() was not called' );
|
||||
$this->assertStringNotContainsString(
|
||||
'// phpcs:ignore',
|
||||
(string) $captured_sql,
|
||||
'SQL string must not contain phpcs:ignore comment — MariaDB rejects it'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Generation_Status_Poller (R2/R3 resilience).
|
||||
*
|
||||
* Covers:
|
||||
* - R2(a): update_generation_stage() renews the transient TTL on every call.
|
||||
* - R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
|
||||
* when lock age > STATUS_TTL and returns status:error with an infrastructure
|
||||
* timeout message.
|
||||
* - R3: on_ajax_generation_status() returns status:running (with started_at)
|
||||
* when the transient is absent but the lock is held within TTL.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Core
|
||||
*/
|
||||
|
||||
namespace PRAutoBlogger\Tests\Core;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
|
||||
class GenerationStatusPollerTest extends BaseTestCase {
|
||||
|
||||
/** @var \PHPUnit\Framework\MockObject\MockObject Mock $wpdb. */
|
||||
private $wpdb;
|
||||
|
||||
/** @var array Captured wp_send_json_success payloads. */
|
||||
private array $json_success = array();
|
||||
|
||||
/** @var array Captured wp_send_json_error payloads. */
|
||||
private array $json_error = array();
|
||||
|
||||
/** @var array Captured transient writes: [key, value, ttl]. */
|
||||
private array $transient_writes = array();
|
||||
|
||||
/** @var mixed Current transient value. */
|
||||
private $transient_value = false;
|
||||
|
||||
/** @var int|null Mocked lock acquired_at timestamp (null = not held). */
|
||||
private ?int $lock_acquired_at = null;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->json_success = array();
|
||||
$this->json_error = array();
|
||||
$this->transient_writes = array();
|
||||
$this->transient_value = false;
|
||||
$this->lock_acquired_at = null;
|
||||
|
||||
$this->wpdb = $this->create_mock_wpdb();
|
||||
// Generation_Lock uses $wpdb->options in acquire/release/get_acquired_at.
|
||||
$this->wpdb->options = 'wp_options';
|
||||
$GLOBALS['wpdb'] = $this->wpdb;
|
||||
|
||||
// Stub nonce check and capability check (always pass in unit tests).
|
||||
Functions\when( 'check_ajax_referer' )->justReturn( true );
|
||||
Functions\when( 'current_user_can' )->justReturn( true );
|
||||
|
||||
// Transient stubs.
|
||||
Functions\when( 'get_transient' )->alias( function( $key ) {
|
||||
return $this->transient_value;
|
||||
} );
|
||||
Functions\when( 'set_transient' )->alias( function( $key, $value, $ttl ) {
|
||||
$this->transient_writes[] = array( $key, $value, $ttl );
|
||||
return true;
|
||||
} );
|
||||
|
||||
// wp_send_json stubs — capture payload, mark test as "sent".
|
||||
Functions\when( 'wp_send_json_success' )->alias( function( $data ) {
|
||||
$this->json_success[] = $data;
|
||||
} );
|
||||
Functions\when( 'wp_send_json_error' )->alias( function( $data ) {
|
||||
$this->json_error[] = $data;
|
||||
} );
|
||||
|
||||
// Generation_Lock::get_acquired_at() — return our test value.
|
||||
// We stub the static method via $wpdb since it does a DB query.
|
||||
$this->wpdb->method( 'prepare' )->willReturnCallback(
|
||||
function ( $sql, ...$args ) {
|
||||
return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */';
|
||||
}
|
||||
);
|
||||
$this->wpdb->method( 'get_var' )->willReturnCallback(
|
||||
function ( $sql ) {
|
||||
// Return the lock timestamp when querying the lock option.
|
||||
if ( false !== strpos( (string) $sql, 'prautoblogger_generation_lock' ) ) {
|
||||
return null !== $this->lock_acquired_at ? (string) $this->lock_acquired_at : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
// Run_State and Run_Context stubs — no DB tables in unit tests.
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
Functions\when( 'get_option' )->justReturn( false );
|
||||
Functions\when( 'delete_option' )->justReturn( true );
|
||||
Functions\when( 'delete_transient' )->justReturn( true );
|
||||
|
||||
// Logger stub.
|
||||
Functions\when( 'wp_date' )->returnArg();
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
\PRAutoBlogger_Run_State::flush_cache();
|
||||
unset( $GLOBALS['wpdb'] );
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// R2(a): update_generation_stage() renews transient TTL
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* update_generation_stage() must renew the transient with STATUS_TTL on
|
||||
* every call so the transient outlasts a long LLM call.
|
||||
*/
|
||||
public function test_update_generation_stage_renews_transient_ttl(): void {
|
||||
// Seed an existing running transient.
|
||||
$this->transient_value = array(
|
||||
'status' => 'running',
|
||||
'stage' => 'old stage',
|
||||
'started' => time() - 30,
|
||||
'last_updated' => time() - 30,
|
||||
);
|
||||
|
||||
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
||||
$poller->update_generation_stage( 'Analyzing sources...' );
|
||||
|
||||
$this->assertCount( 1, $this->transient_writes, 'set_transient must be called once' );
|
||||
[ $key, $value, $ttl ] = $this->transient_writes[0];
|
||||
$this->assertSame( \PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, $key );
|
||||
$this->assertSame( \PRAutoBlogger_Generation_Status_Poller::STATUS_TTL, $ttl,
|
||||
'TTL must be renewed to STATUS_TTL on every stage update' );
|
||||
$this->assertSame( 'Analyzing sources...', $value['stage'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* update_generation_stage() is a no-op when the transient is absent
|
||||
* (prevents spurious "running" writes after a run has completed).
|
||||
*/
|
||||
public function test_update_generation_stage_no_op_when_transient_absent(): void {
|
||||
$this->transient_value = false; // absent.
|
||||
|
||||
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
||||
$poller->update_generation_stage( 'Stage text' );
|
||||
|
||||
$this->assertEmpty( $this->transient_writes, 'set_transient must NOT be called when transient is absent' );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// R3: transient absent, lock held within TTL → return status:running
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* R3: When the status transient has expired but the lock is held within
|
||||
* STATUS_TTL, the poller must return status:running so the Generate Now
|
||||
* button does not silently reset to idle during long background runs.
|
||||
*/
|
||||
public function test_missing_transient_but_fresh_lock_returns_running(): void {
|
||||
$this->transient_value = false; // transient gone.
|
||||
$this->lock_acquired_at = time() - 60; // lock held 60s — well within TTL.
|
||||
|
||||
// Stub _POST for the nonce.
|
||||
$_POST = array( 'nonce' => 'fake' );
|
||||
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
||||
$poller->on_ajax_generation_status();
|
||||
|
||||
$this->assertCount( 1, $this->json_success, 'wp_send_json_success must be called once' );
|
||||
$response = $this->json_success[0];
|
||||
$this->assertSame( 'running', $response['status'],
|
||||
'R3: status must be running when transient absent but lock held within TTL' );
|
||||
$this->assertArrayHasKey( 'started', $response,
|
||||
'R3: response must include started timestamp from lock' );
|
||||
$this->assertSame( $this->lock_acquired_at, $response['started'],
|
||||
'R3: started must equal the lock acquired_at timestamp' );
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// R2(b): transient absent, lock held > STATUS_TTL → abort + status:error
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* R2(b): When the status transient is absent and the lock has been held
|
||||
* longer than STATUS_TTL, the poller must mark the run failed and return
|
||||
* status:error with an "infrastructure timeout" message.
|
||||
*/
|
||||
public function test_missing_transient_with_expired_lock_marks_failed_and_returns_error(): void {
|
||||
$this->transient_value = false;
|
||||
$ttl = \PRAutoBlogger_Generation_Status_Poller::STATUS_TTL;
|
||||
$this->lock_acquired_at = time() - ( $ttl + 60 ); // Lock held TTL+60s.
|
||||
|
||||
$_POST = array( 'nonce' => 'fake' );
|
||||
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
||||
$poller->on_ajax_generation_status();
|
||||
|
||||
$this->assertCount( 1, $this->json_success, 'wp_send_json_success must be called' );
|
||||
$response = $this->json_success[0];
|
||||
$this->assertSame( 'error', $response['status'],
|
||||
'R2(b): status must be error when lock is expired-stale' );
|
||||
$this->assertStringContainsString(
|
||||
'infrastructure timeout',
|
||||
strtolower( $response['message'] ?? '' ),
|
||||
'R2(b): error message must mention infrastructure timeout'
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Idle path: no transient, no lock
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When both the transient and the lock are absent, the poller must
|
||||
* return status:idle (normal rest state between runs).
|
||||
*/
|
||||
public function test_no_transient_no_lock_returns_idle(): void {
|
||||
$this->transient_value = false;
|
||||
$this->lock_acquired_at = null; // no lock.
|
||||
|
||||
$_POST = array( 'nonce' => 'fake' );
|
||||
$poller = new \PRAutoBlogger_Generation_Status_Poller();
|
||||
$poller->on_ajax_generation_status();
|
||||
|
||||
$this->assertCount( 1, $this->json_success );
|
||||
$this->assertSame( 'idle', $this->json_success[0]['status'] );
|
||||
}
|
||||
}
|
||||
@@ -137,76 +137,4 @@ class RunStageStateTest extends BaseTestCase {
|
||||
$this->assertFalse( \PRAutoBlogger_Run_Stage_State::is_done( 'run-1', 'draft' ) );
|
||||
$this->assertNull( \PRAutoBlogger_Run_Stage_State::get_output( 'run-1', 'draft' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* start(role) → done(role) → is_done(role) must be true; is_done('') must
|
||||
* be false — this catches the v0.18.2 regression where done() passed ''
|
||||
* while start() passed a real role, so done() addressed a different row.
|
||||
*
|
||||
* In this test the wpdb mock records inserts via query() and simulates
|
||||
* the SELECT via get_row():
|
||||
* is_done('review','editor') → get_row finds the done row → true.
|
||||
* is_done('review','') → resolve_role→'editor'; primary: miss;
|
||||
* legacy fallback (role=''): miss → false.
|
||||
*/
|
||||
public function test_role_tagged_start_and_done_are_symmetric(): void {
|
||||
$get_row_results = array(
|
||||
array( 'status' => 'done', 'meta_json' => null ), // primary hit for 'editor'.
|
||||
null, // primary miss for legacy check (role='editor' resolved, not '').
|
||||
null, // legacy fallback (role='') also not found.
|
||||
);
|
||||
$call_index = 0;
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'query' )->willReturn( 1 );
|
||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
||||
static function () use ( &$get_row_results, &$call_index ) {
|
||||
$result = $get_row_results[ $call_index ] ?? null;
|
||||
++$call_index;
|
||||
return $result;
|
||||
}
|
||||
);
|
||||
|
||||
\PRAutoBlogger_Run_Stage_State::start( 'run-2', 'review', 'editor', 'idea:xyz' );
|
||||
\PRAutoBlogger_Run_Stage_State::done( 'run-2', 'review', 'editor', 'idea:xyz' );
|
||||
|
||||
// Explicit role finds the done row.
|
||||
$this->assertTrue(
|
||||
\PRAutoBlogger_Run_Stage_State::is_done( 'run-2', 'review', 'editor', 'idea:xyz' ),
|
||||
'is_done with explicit role should be true'
|
||||
);
|
||||
// '' resolves to 'editor'; primary misses; legacy '' fallback also misses.
|
||||
$this->assertFalse(
|
||||
\PRAutoBlogger_Run_Stage_State::is_done( 'run-2', 'review', '', 'idea:xyz' ),
|
||||
'is_done with legacy empty role should be false (no legacy row exists)'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mid-run-upgrade resume: a row created by v0.18.1 (agent_role='') must
|
||||
* still be found by is_done('') under v0.18.2 via the legacy fallback.
|
||||
* resolve_role('review','') → 'editor'; primary query misses; legacy
|
||||
* fallback (role='') hits → is_done returns true.
|
||||
*/
|
||||
public function test_legacy_empty_role_row_is_found_on_fallback(): void {
|
||||
$call_index = 0;
|
||||
$get_row_results = array(
|
||||
null, // primary query (role='editor'): miss.
|
||||
array( 'status' => 'done', 'meta_json' => null ), // legacy fallback (role=''): hit.
|
||||
);
|
||||
$this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' );
|
||||
$this->wpdb->method( 'get_row' )->willReturnCallback(
|
||||
static function () use ( &$get_row_results, &$call_index ) {
|
||||
$result = $get_row_results[ $call_index ] ?? null;
|
||||
++$call_index;
|
||||
return $result;
|
||||
}
|
||||
);
|
||||
|
||||
// is_done('') resolves to 'editor', misses, falls back to role='',
|
||||
// finds the v0.18.1 row → true.
|
||||
$this->assertTrue(
|
||||
\PRAutoBlogger_Run_Stage_State::is_done( 'run-3', 'review', '', 'idea:abc' ),
|
||||
'v0.18.1 empty-role row must be found via legacy fallback'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
/**
|
||||
* Tests for PRAutoBlogger_Runware_Model_Catalog.
|
||||
*
|
||||
* Validates: normalize_models against real modelSearch fixture; cooldown behavior
|
||||
* (failure → no retry within window → retry after); fallback served when cache empty;
|
||||
* success path clears failure timestamp; cache version key v2 is used throughout.
|
||||
* Validates live sync, fallback logic, cache staleness, and pricing merge.
|
||||
*
|
||||
* @package PRAutoBlogger\Tests\Providers
|
||||
*/
|
||||
@@ -13,267 +11,47 @@ namespace PRAutoBlogger\Tests\Providers;
|
||||
|
||||
use PRAutoBlogger\Tests\BaseTestCase;
|
||||
use Brain\Monkey\Functions;
|
||||
use Brain\Monkey\Actions;
|
||||
|
||||
class RunwareModelCatalogTest extends BaseTestCase {
|
||||
|
||||
/** Shared in-memory option store reset per test. */
|
||||
private array $options = array();
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->options = array();
|
||||
$this->stub_options();
|
||||
// Mock WP options for cache simulation.
|
||||
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) {
|
||||
static $options = array();
|
||||
return $options[ $key ] ?? $default;
|
||||
} );
|
||||
Functions\when( 'update_option' )->alias( function ( $key, $value ) {
|
||||
static $options = array();
|
||||
$options[ $key ] = $value;
|
||||
return true;
|
||||
} );
|
||||
Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private function stub_options(): void {
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( $key, $default = false ) {
|
||||
return $this->options[ $key ] ?? $default;
|
||||
}
|
||||
);
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $key, $value ) {
|
||||
$this->options[ $key ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'delete_option' )->alias(
|
||||
function ( $key ) {
|
||||
unset( $this->options[ $key ] );
|
||||
return true;
|
||||
}
|
||||
);
|
||||
Functions\when( 'time' )->justReturn( 1704067200 ); // 2024-01-01 00:00:00 UTC
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fixture as a decoded page array matching fetch_page() output.
|
||||
*
|
||||
* @return array{results: array, totalResults: int}
|
||||
*/
|
||||
private function get_fixture_page(): array {
|
||||
$fixture_path = __DIR__ . '/../../fixtures/runware-model-search-page1.json';
|
||||
$json = file_get_contents( $fixture_path );
|
||||
$decoded = json_decode( $json, true );
|
||||
$item = $decoded['data'][0];
|
||||
return array(
|
||||
'results' => $item['results'],
|
||||
'totalResults' => $item['totalResults'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fake HTTP response array for wp_remote_post stubs.
|
||||
*
|
||||
* @param int $status HTTP status code.
|
||||
* @param string $body Response body JSON.
|
||||
* @return array
|
||||
*/
|
||||
private function make_http_response( int $status, string $body ): array {
|
||||
return array(
|
||||
'response' => array( 'code' => $status ),
|
||||
'body' => $body,
|
||||
);
|
||||
}
|
||||
|
||||
private function stub_http_helpers(): void {
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
||||
function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); }
|
||||
);
|
||||
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
||||
function ( $r ) { return (string) ( $r['body'] ?? '' ); }
|
||||
);
|
||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
||||
}
|
||||
|
||||
// ── normalize_models tests (via Fetcher) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* normalize_models filters to io:text-to-image and maps air → id,
|
||||
* comment → description. Non-t2i models are excluded.
|
||||
*
|
||||
* Uses the real modelSearch fixture (3 t2i + 1 text LLM + 1 upscaler).
|
||||
* Expects only the 3 t2i models to pass through.
|
||||
*/
|
||||
public function test_normalize_models_filters_to_text_to_image(): void {
|
||||
$fixture = $this->get_fixture_page();
|
||||
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||
$result = $fetcher->normalize_models( $fixture['results'] );
|
||||
|
||||
// Fixture has 3 t2i models (runware:100@1, runware:101@1, runware:400@2)
|
||||
// and 2 non-t2i (text LLM + upscaler).
|
||||
$this->assertCount( 3, $result, 'Should include exactly 3 io:text-to-image models' );
|
||||
|
||||
$ids = array_column( $result, 'id' );
|
||||
$this->assertContains( 'runware:100@1', $ids );
|
||||
$this->assertContains( 'runware:101@1', $ids );
|
||||
$this->assertContains( 'runware:400@2', $ids );
|
||||
$this->assertNotContains( 'some-text-model:llm@1', $ids, 'Text LLM must be excluded' );
|
||||
$this->assertNotContains( 'upscale-model:upscaler@1', $ids, 'Upscaler must be excluded' );
|
||||
}
|
||||
|
||||
/** normalize_models uses air as id, name as name, comment as description. */
|
||||
public function test_normalize_models_maps_fields_correctly(): void {
|
||||
$fixture = $this->get_fixture_page();
|
||||
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||
$result = $fetcher->normalize_models( $fixture['results'] );
|
||||
|
||||
$schnell = null;
|
||||
foreach ( $result as $m ) {
|
||||
if ( 'runware:100@1' === $m['id'] ) {
|
||||
$schnell = $m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertNotNull( $schnell );
|
||||
$this->assertSame( 'runware:100@1', $schnell['id'] );
|
||||
$this->assertSame( 'FLUX.1 schnell', $schnell['name'] );
|
||||
$this->assertSame( 'runware', $schnell['provider'] );
|
||||
$this->assertNull( $schnell['cost_per_image'], 'Pricing is null before merge_pricing runs' );
|
||||
$this->assertSame( array( 'image_generation' ), $schnell['capabilities'] );
|
||||
$this->assertSame( 'Fast 4-step text-to-image model', $schnell['description'] );
|
||||
}
|
||||
|
||||
/** normalize_models skips entries with empty air field. */
|
||||
public function test_normalize_models_skips_empty_air(): void {
|
||||
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||
$raw = array(
|
||||
array(
|
||||
'air' => '',
|
||||
'name' => 'No Air Model',
|
||||
'capabilities' => array( 'io:text-to-image' ),
|
||||
'comment' => '',
|
||||
),
|
||||
array(
|
||||
'air' => 'runware:100@1',
|
||||
'name' => 'Valid',
|
||||
'capabilities' => array( 'io:text-to-image' ),
|
||||
'comment' => 'OK',
|
||||
),
|
||||
);
|
||||
|
||||
$result = $fetcher->normalize_models( $raw );
|
||||
$this->assertCount( 1, $result );
|
||||
$this->assertSame( 'runware:100@1', $result[0]['id'] );
|
||||
}
|
||||
|
||||
/** normalize_models returns empty array for empty input. */
|
||||
public function test_normalize_models_empty_input(): void {
|
||||
$fetcher = new \PRAutoBlogger_Runware_Catalog_Fetcher();
|
||||
$this->assertSame( array(), $fetcher->normalize_models( array() ) );
|
||||
}
|
||||
|
||||
// ── Failure cooldown tests ────────────────────────────────────────────────
|
||||
|
||||
/** No failure on record → is_in_failure_cooldown returns false. */
|
||||
public function test_no_failure_cooldown_when_no_failure_recorded(): void {
|
||||
/** Sync with empty or missing API key returns false. */
|
||||
public function test_sync_no_api_key(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertFalse( $catalog->is_in_failure_cooldown() );
|
||||
}
|
||||
|
||||
/** Failure recorded at t=now → cooldown active within 1h window. */
|
||||
public function test_cooldown_active_within_window(): void {
|
||||
$now = 1704067200;
|
||||
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now;
|
||||
// time() is stubbed to $now, so age = 0 < 3600 = cooldown.
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertTrue( $catalog->is_in_failure_cooldown() );
|
||||
}
|
||||
|
||||
/** Failure recorded > 1h ago → cooldown expired, retry permitted. */
|
||||
public function test_cooldown_expired_after_window(): void {
|
||||
$now = 1704067200;
|
||||
$past = $now - 3601; // More than 1h ago.
|
||||
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $past;
|
||||
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertFalse( $catalog->is_in_failure_cooldown() );
|
||||
}
|
||||
|
||||
/** Custom cooldown from settings is respected. */
|
||||
public function test_cooldown_uses_setting_value(): void {
|
||||
$now = 1704067200;
|
||||
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now - 100;
|
||||
// Default 3600s would make this expired (100 < 3600), but set custom = 60s.
|
||||
// 100s > 60s so it should be expired.
|
||||
$this->options['prautoblogger_runware_catalog_failure_cooldown_seconds'] = 60;
|
||||
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertFalse( $catalog->is_in_failure_cooldown() );
|
||||
}
|
||||
|
||||
/** Sync failure sets last_failure_at and does not clear success timestamp. */
|
||||
public function test_sync_failure_records_failure_timestamp(): void {
|
||||
// Mock HTTP 401 to force sync failure.
|
||||
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||
$this->stub_http_helpers();
|
||||
Functions\when( 'wp_remote_post' )->justReturn(
|
||||
$this->make_http_response( 401, '{"errors":[{"message":"Unauthorized"}]}' )
|
||||
);
|
||||
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$result = $catalog->sync();
|
||||
|
||||
$result = $catalog->sync();
|
||||
$this->assertFalse( $result );
|
||||
$this->assertNotNull( $catalog->get_last_failure_at(), 'Failure timestamp should be recorded' );
|
||||
}
|
||||
|
||||
/** After failure, get_models suppresses sync within cooldown window. */
|
||||
public function test_get_models_suppresses_sync_during_cooldown(): void {
|
||||
$now = 1704067200;
|
||||
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now; // just failed.
|
||||
$this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 90000; // stale cache.
|
||||
|
||||
$this->stub_http_helpers();
|
||||
// wp_remote_post should NOT be called during cooldown.
|
||||
Functions\expect( 'wp_remote_post' )->never();
|
||||
|
||||
/** get_models returns fallback if no cache and sync fails. */
|
||||
public function test_get_models_fallback_on_sync_failure(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$models = $catalog->get_models();
|
||||
|
||||
// Should fall back to hardcoded fallback list.
|
||||
$this->assertNotEmpty( $models );
|
||||
$ids = array_column( $models, 'id' );
|
||||
$this->assertContains( 'runware:100@1', $ids, 'Fallback schnell model should be present' );
|
||||
}
|
||||
|
||||
/** Successful sync clears failure timestamp. */
|
||||
public function test_successful_sync_clears_failure_timestamp(): void {
|
||||
$now = 1704067200;
|
||||
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||
$this->options['prautoblogger_runware_catalog_last_failure_at'] = $now - 7200; // old failure.
|
||||
|
||||
$fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
|
||||
$this->stub_http_helpers();
|
||||
Functions\when( 'wp_remote_post' )->justReturn(
|
||||
$this->make_http_response( 200, $fixture_body )
|
||||
);
|
||||
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$result = $catalog->sync();
|
||||
|
||||
$this->assertTrue( $result, 'Sync should succeed with valid fixture response' );
|
||||
$this->assertNull( $catalog->get_last_failure_at(), 'Success should clear failure timestamp' );
|
||||
}
|
||||
|
||||
// ── Fallback list tests ───────────────────────────────────────────────────
|
||||
|
||||
/** get_models returns fallback when no cache and sync fails. */
|
||||
public function test_get_models_returns_fallback_when_no_cache(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$models = $catalog->get_models();
|
||||
$models = $catalog->get_models();
|
||||
|
||||
// Should have the fallback list (15 Runware models).
|
||||
$this->assertIsArray( $models );
|
||||
$this->assertNotEmpty( $models );
|
||||
$this->assertGreaterThan( 0, count( $models ) );
|
||||
|
||||
// Check at least one expected fallback model exists.
|
||||
$schnell_exists = false;
|
||||
foreach ( $models as $model ) {
|
||||
if ( 'runware:100@1' === ( $model['id'] ?? '' ) ) {
|
||||
$schnell_exists = true;
|
||||
$this->assertSame( 'FLUX.1 schnell (Runware)', $model['name'] );
|
||||
$this->assertSame( 'runware', $model['provider'] );
|
||||
break;
|
||||
}
|
||||
@@ -281,12 +59,28 @@ class RunwareModelCatalogTest extends BaseTestCase {
|
||||
$this->assertTrue( $schnell_exists, 'Fallback should include FLUX.1 schnell' );
|
||||
}
|
||||
|
||||
/** Fallback list is never empty (even with no key configured). */
|
||||
/** is_stale returns true if never synced. */
|
||||
public function test_is_stale_never_synced(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertTrue( $catalog->is_stale() );
|
||||
}
|
||||
|
||||
/** get_last_synced_at returns null if never synced. */
|
||||
public function test_get_last_synced_at_never(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertNull( $catalog->get_last_synced_at() );
|
||||
}
|
||||
|
||||
/** Fallback list is never empty. */
|
||||
public function test_get_models_never_empty(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$models = $catalog->get_models();
|
||||
|
||||
// Even with no cache and failed sync, get_models should return the fallback.
|
||||
$models = $catalog->get_models();
|
||||
$this->assertIsArray( $models );
|
||||
$this->assertNotEmpty( $models );
|
||||
|
||||
// All models should have required fields.
|
||||
foreach ( $models as $model ) {
|
||||
$this->assertArrayHasKey( 'id', $model );
|
||||
$this->assertArrayHasKey( 'name', $model );
|
||||
@@ -295,24 +89,13 @@ class RunwareModelCatalogTest extends BaseTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/** All fallback models carry the image_generation capability. */
|
||||
public function test_fallback_models_have_image_generation_capability(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$models = $catalog->get_models();
|
||||
|
||||
foreach ( $models as $model ) {
|
||||
$this->assertIsArray( $model['capabilities'] );
|
||||
$this->assertContains( 'image_generation', $model['capabilities'] );
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback schnell and dev have correct pricing. */
|
||||
/** Fallback models have correct pricing. */
|
||||
public function test_fallback_models_have_pricing(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$models = $catalog->get_models();
|
||||
$models = $catalog->get_models();
|
||||
|
||||
$schnell = null;
|
||||
$dev = null;
|
||||
$dev = null;
|
||||
foreach ( $models as $model ) {
|
||||
if ( 'runware:100@1' === $model['id'] ) {
|
||||
$schnell = $model;
|
||||
@@ -324,98 +107,83 @@ class RunwareModelCatalogTest extends BaseTestCase {
|
||||
|
||||
$this->assertNotNull( $schnell );
|
||||
$this->assertEqualsWithDelta( 0.0006, $schnell['cost_per_image'], 0.000001 );
|
||||
|
||||
$this->assertNotNull( $dev );
|
||||
$this->assertEqualsWithDelta( 0.02, $dev['cost_per_image'], 0.000001 );
|
||||
}
|
||||
|
||||
// ── Happy path (sync → cache → get_models) ───────────────────────────────
|
||||
/** Fallback models all have 'image_generation' capability. */
|
||||
public function test_fallback_models_have_capabilities(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$models = $catalog->get_models();
|
||||
|
||||
/** Successful sync caches models; get_models returns them from cache. */
|
||||
foreach ( $models as $model ) {
|
||||
$this->assertIsArray( $model['capabilities'] );
|
||||
$this->assertContains( 'image_generation', $model['capabilities'] );
|
||||
}
|
||||
}
|
||||
|
||||
/** Happy path: successful sync caches models and get_models returns them. */
|
||||
public function test_sync_success_caches_and_returns_models(): void {
|
||||
$fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
|
||||
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||
|
||||
$this->stub_http_helpers();
|
||||
Functions\when( 'wp_remote_post' )->justReturn(
|
||||
$this->make_http_response( 200, $fixture_body )
|
||||
// The mocked API response body — models must have taskType=imageInference
|
||||
// so normalize_models() doesn't filter them out.
|
||||
$fake_body = json_encode(
|
||||
array(
|
||||
'data' => array(
|
||||
array(
|
||||
'id' => 'test-model-1',
|
||||
'name' => 'Test Model 1',
|
||||
'taskType' => 'imageInference',
|
||||
),
|
||||
array(
|
||||
'id' => 'test-model-2',
|
||||
'name' => 'Test Model 2',
|
||||
'taskType' => 'imageInference',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$fake_response = array(
|
||||
'response' => array( 'code' => 200 ),
|
||||
'body' => $fake_body,
|
||||
);
|
||||
|
||||
Functions\when( 'wp_remote_post' )->justReturn( $fake_response );
|
||||
// Alias the WP response-parsing helpers so they work against our array.
|
||||
Functions\when( 'wp_remote_retrieve_response_code' )->alias(
|
||||
function ( $r ) { return (int) ( $r['response']['code'] ?? 0 ); }
|
||||
);
|
||||
Functions\when( 'wp_remote_retrieve_body' )->alias(
|
||||
function ( $r ) { return (string) ( $r['body'] ?? '' ); }
|
||||
);
|
||||
Functions\when( 'is_wp_error' )->justReturn( false );
|
||||
|
||||
// Shared options store so get_option/update_option stay in sync
|
||||
// within this test. The API key must be non-empty for sync() to proceed.
|
||||
$store = array( 'prautoblogger_runware_api_key' => 'test-api-key-plain' );
|
||||
Functions\when( 'get_option' )->alias(
|
||||
function ( $key, $default = false ) use ( &$store ) {
|
||||
return $store[ $key ] ?? $default;
|
||||
}
|
||||
);
|
||||
Functions\when( 'update_option' )->alias(
|
||||
function ( $key, $value ) use ( &$store ) {
|
||||
$store[ $key ] = $value;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
// Sync should succeed: API key present, HTTP 200, 2 imageInference models.
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$result = $catalog->sync();
|
||||
|
||||
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid fixture' );
|
||||
$result = $catalog->sync();
|
||||
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid models' );
|
||||
|
||||
// get_models() should now return the synced list from cache.
|
||||
$models = $catalog->get_models();
|
||||
$this->assertIsArray( $models );
|
||||
// Fixture has 3 t2i models (schnell, dev, klein 9B).
|
||||
$this->assertCount( 3, $models, 'get_models should return the 3 cached t2i models' );
|
||||
|
||||
$ids = array_column( $models, 'id' );
|
||||
$this->assertContains( 'runware:100@1', $ids );
|
||||
$this->assertContains( 'runware:101@1', $ids );
|
||||
$this->assertContains( 'runware:400@2', $ids );
|
||||
}
|
||||
|
||||
/** Synced models have pricing merged from the pricing class. */
|
||||
public function test_synced_models_have_pricing_merged(): void {
|
||||
$fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
|
||||
$this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
|
||||
|
||||
$this->stub_http_helpers();
|
||||
Functions\when( 'wp_remote_post' )->justReturn(
|
||||
$this->make_http_response( 200, $fixture_body )
|
||||
);
|
||||
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$catalog->sync();
|
||||
$models = $catalog->get_models();
|
||||
|
||||
$schnell = null;
|
||||
foreach ( $models as $m ) {
|
||||
if ( 'runware:100@1' === $m['id'] ) {
|
||||
$schnell = $m;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull( $schnell );
|
||||
$this->assertEqualsWithDelta( 0.0006, (float) $schnell['cost_per_image'], 0.000001 );
|
||||
}
|
||||
|
||||
// ── Stale / absent cache tests ────────────────────────────────────────────
|
||||
|
||||
/** is_stale returns true if never synced. */
|
||||
public function test_is_stale_never_synced(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertTrue( $catalog->is_stale() );
|
||||
}
|
||||
|
||||
/** is_stale returns false for a fresh cache (synced < 24h ago). */
|
||||
public function test_is_stale_fresh_cache(): void {
|
||||
$now = 1704067200;
|
||||
$this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 3600; // 1h ago.
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertFalse( $catalog->is_stale() );
|
||||
}
|
||||
|
||||
/** is_stale returns true when cache is older than 24h. */
|
||||
public function test_is_stale_old_cache(): void {
|
||||
$now = 1704067200;
|
||||
$this->options['prautoblogger_runware_model_cache_updated_at'] = $now - 90000; // > 24h ago.
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertTrue( $catalog->is_stale() );
|
||||
}
|
||||
|
||||
/** get_last_synced_at returns null if never synced. */
|
||||
public function test_get_last_synced_at_never(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$this->assertNull( $catalog->get_last_synced_at() );
|
||||
}
|
||||
|
||||
/** Sync with no API key returns false without recording a failure. */
|
||||
public function test_sync_no_api_key(): void {
|
||||
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
|
||||
$result = $catalog->sync();
|
||||
$this->assertFalse( $result );
|
||||
// No key → not a transient failure, no cooldown recorded.
|
||||
$this->assertNull( $catalog->get_last_failure_at() );
|
||||
$this->assertCount( 2, $models );
|
||||
$this->assertSame( 'test-model-1', $models[0]['id'] );
|
||||
$this->assertSame( 'test-model-2', $models[1]['id'] );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user