Compare commits

..

13 Commits

Author SHA1 Message Date
peptiderepo
22ae7e7bd3 ci: harden cto-review.yml severity KeyError (v0.18.3)
QA (seq 43) identified a content-triggered KeyError in the CTO-Review
postprocessor: when a model returns a concerns[] item without a 'severity'
key, line 454 crashed with KeyError instead of degrading.

  sev = c[severity].upper()        # before — hard KeyError
  sev = c.get("severity", "UNKNOWN").upper()  # after — degrades gracefully

The JSON schema marks severity as required but the postprocessor does not
enforce it — a model deviating on a large diff (91K bytes for f10e177 vs
41K for 02e9aab) triggered the crash. The fix surfaces UNKNOWN-severity
concerns rather than killing the check.

No other workflow changes.

Agent-Session: prab-v0183-20260612
2026-06-12 03:52:15 +00:00
peptiderepo
77ef941b3d fix: document STATUS_TTL/API_TIMEOUT/MAX_RETRIES safety invariant (v0.18.3)
Add comment at the PRAUTOBLOGGER_MAX_RETRIES + PRAUTOBLOGGER_API_TIMEOUT_SECONDS
constant definitions documenting the dependency:

  API_TIMEOUT_SECONDS × MAX_RETRIES (540s) must stay < STATUS_TTL (600s)

Current product: 180s × 3 = 540s — 60s margin. Prevents constant-drift
regression: raising either constant without checking the inequality could cause
a fully-retrying LLM call to exhaust the status-transient TTL within a single
stage, triggering a false orphan-run abort (R2b path in poller).

Agent-Session: prab-v0183-20260612
2026-06-12 03:51:43 +00:00
peptiderepo
732ef9b1fb docs: ARCHITECTURE.md — add poller + fetcher to file tree; rewrite fallback detection
P1-1: class-generation-status-poller.php added under includes/; executor
description updated to reflect AJAX delegation (no longer 'start + status').

P1-2: class-runware-catalog-fetcher.php added under includes/providers/;
class-runware-model-catalog.php also added (was missing from tree).

P1-3: 'Fallback detection' blurb rewritten. Old: 180s/post-meta check.
Actual: transient renewed per-stage via update_generation_stage(); lock within
STATUS_TTL → R3 (running); lock > STATUS_TTL → R2b (abort_orphaned_run,
status:error). Daily Run_Reaper remains backstop.

Agent-Session: prab-v0183-20260612
2026-06-12 03:51:00 +00:00
peptiderepo
f10e1772b3 fix: migrate Runware catalog to modelSearch; failure cooldown (v0.18.3 R4)
Runware retired taskType:models; all 42 daily catalog 401s come from this

deprecated endpoint. Migrated to auth+modelSearch pair per Runware's

current API. Paginator fetches up to 5 pages x 100 = 500 models, filters

client-side to io:text-to-image in capabilities[]. Uses air identifier as

model id. Cache key bumped to v2 (prautoblogger_runware_model_cache_v2)

to invalidate v1 rows that used old taskType/requiresImage shape.

Failure cooldown: sync() records prautoblogger_runware_catalog_last_failure_at

on every error path. get_models() gates re-trigger via is_in_failure_cooldown()

which reads prautoblogger_runware_catalog_failure_cooldown_seconds (default

3600s). Success clears the timestamp. Caps cascade to ~24 errors/day.

300-line rule: fetching/normalization extracted to new companion class

PRAutoBlogger_Runware_Catalog_Fetcher (class-runware-catalog-fetcher.php,

205 lines). Catalog class: 261 lines. Autoloader picks up the new file.

Tests: normalize_models against real modelSearch fixture (redacted key);

cooldown behavior (failure->active->expired); sync clears timestamp; fallback

list always served when cache empty. 328/328 PHPUnit green.

Agent-Session: prab-v0183-20260612
2026-06-12 03:33:20 +00:00
peptiderepo
02e9aabea6 fix(tests): add wpdb->posts and wpdb->options to mock setup
AnalysisPromptsTest: analysis-prompts.php uses $wpdb->posts in the
JOIN clause ({->posts}); mock stdClass must declare posts property.

GenerationStatusPollerTest: Generation_Lock queries {->options}
in acquire/release/get_acquired_at; mock stdClass must declare options.

Agent-Session: prab-v0183-20260612
2026-06-12 03:18:10 +00:00
peptiderepo
50369bb644 fix(v0183): GA4 SQL fix + manual-run resilience + honest status
R1: move phpcs:ignore out of SQL string in class-analysis-prompts.php:81
    (same pattern as v0.18.2 cost-reporter fix). GA4 self-improvement loop
    has returned empty context on every pipeline run since v0.16.0 — this
    restores get_performance_context() to the LLM. Sibling occurrence also
    fixed in class-post-assembler.php:112.

R2(a): update_generation_stage() now renews STATUS_TTL on every stage call
    so the transient outlasts long LLM calls during background runs.

R2(b): on_ajax_generation_status() detects lock-held-but-transient-gone
    when lock age > STATUS_TTL (= Hostinger background-process kill).
    Marks stuck run failed, releases lock, returns status:error with
    'infrastructure timeout' message. Daily reaper remains the backstop.

R3: when transient absent but lock held within TTL, return status:running
    (with started_at from lock) instead of status:idle.

Executor split: class-executor.php was 351 lines (>300). AJAX generation
    handlers extracted to new class-generation-status-poller.php (260 lines);
    executor now 226 lines. Public hook wiring unchanged via delegation.
    Generation_Lock gains get_acquired_at() for R2b/R3 lock-age checks.

Tests: AnalysisPromptsTest (regression SQL + behavioral context shaping),
       GenerationStatusPollerTest (R2a TTL renewal, R2b abort, R3 running,
       idle path).

Closes: R1 (analysis-prompts SQL), R2 (manual-run resilience), R3 (honest status)
Version: 0.18.3
Agent-Session: prab-v0183-20260612
2026-06-12 03:15:14 +00:00
peptiderepo
5ea058d504 style: auto-fix PHPCS violations via phpcbf 2026-06-12 09:16:40 +07:00
peptiderepo
3af65bbe39 fix(stage-state): v0.18.2b — symmetric agent_role via in-class resolve_role seam
QA REJECT-P1 remediation (seq 29). start()/done()/is_done()/get_output()
were asymmetric: start() passed the mapped role but done()/is_done()/
get_output() passed '' via the default, addressing a different unique key
(run_id, stage, agent_role, item_key). On every normal run this orphaned
the start() row in 'running'; on resume is_done() always returned false
→ duplicate-post vector.

Fix: add resolve_role(stage, agent_role) private static seam in
Run_Stage_State. When the caller passes '' the role is derived from
Stage_Display_Map::default_agent_role(stage) before any DB operation.
Non-empty roles (Phase-2 fan-out) pass through unchanged. All four
write/lookup methods (start, done, fail, get) call resolve_role.
is_done() and get_output() go through get() so they benefit too.

Mid-run-upgrade compat (v0.18.1 → v0.18.2): get() falls back to a
legacy role='' row on miss so a run started under v0.18.1 (all rows
have agent_role='') can still resume under v0.18.2. One extra indexed
query, only on miss, only for known stages.

Tests: two new RunStageStateTest cases —
  test_role_tagged_start_and_done_are_symmetric: start+done with
  'editor', assert is_done('editor')=true and is_done('')=false.
  test_legacy_empty_role_row_is_found_on_fallback: resolve to 'editor',
  primary miss, legacy role='' hit → is_done=true.

ARCHITECTURE.md §22: agent_role column and #22 Phase 1 description
updated — no longer 'Phase-2 only / '' default'; now documents Phase 1
population from Stage_Display_Map.

Agent-Session: prab-v0182-maintenance-20260612
2026-06-12 09:16:40 +07:00
peptiderepo
6b1908d628 fix(maintenance): v0.18.2 — four confirmed-live fixes
1. Cost-reporter SQL: phpcs:ignore annotations were embedded inside two SQL
   string literals (get_daily_spend, get_spend_by_stage), causing MariaDB to
   reject both queries and break the Metrics & Costs admin page. Moved annotations
   above the prepare() calls. Regression tests added to CostReporterTest.

2. Collector failure observability: catch block in collect_from_all_sources() now
   logs structured meta (model slug, HTTP status parsed from exception message,
   first 200 chars of error body) so outage incidents are diagnosable post-hoc.

3. run_stages.agent_role back-write: Pipeline_Runner, Article_Worker, and
   Content_Generator were calling Run_Stage_State::start() with agent_role=''.
   All start() call sites now pass Stage_Display_Map::default_agent_role(stage).
   Role written at INSERT only; ON DUPLICATE KEY UPDATE does not include
   agent_role — idempotency-key semantics on resume are unchanged.

4. Runware float deprecation: normalize_seed() applied % to a float
   (microtime(true) * 1000), triggering PHP 8 deprecation on every image
   generation. Fixed with (int) round(...) before the modulo operator.

Refs: convo/prautoblogger/threads/2026-06-pipeline-v2-phase1/25-cto-v0182-maintenance.md
Agent-Session: prab-v0182-maintenance-20260612
2026-06-12 09:16:40 +07:00
peptiderepo
b902216497 fix(settings): sanitize reasoning token cap as numeric on save
QA F4 (P3): prautoblogger_reasoning_max_tokens was missing from the
sanitize_field() numeric allowlist, so it saved as raw text (absint at
read kept runtime safe). Saves as numeric-or-0 now, matching every other
number field. CHANGELOG 0.18.1 entry notes the server-side sanitize (no
new version — 0.18.1 is still unreleased).

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 21:06:46 +07:00
peptiderepo
27ef37fe15 test: split PublisherTest and OpenRouterProviderTest under the 300-line rule
QA F2 (P2): this PR pushed PublisherTest to 333 lines and
OpenRouterProviderTest to 318. Both splits are mechanical (test groups
moved verbatim to a sibling file; setUp duplicated; no test logic or
assertion changes):

- OpenRouterProviderTest (318 -> 169): wire_full_provider() + the two
  v0.18.1 retry-path integration tests move to
  OpenRouterProviderRetryTest.php (223). The remaining file is
  byte-identical to its pre-PR state on main.
- PublisherTest (333 -> 202): the five post-creation side-effect tests
  (taxonomy, tags, run_id log linking, action/filter hooks — incl. the
  two pre-existing skipped ones, moved as-is) move to
  PublisherSideEffectsTest.php (183). Guard tests stay with the
  create_post() funnel tests.

Suite count unchanged (302 tests); all four files pass locally.

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 21:06:46 +07:00
peptiderepo
b7ffce8d88 docs(architecture): document v0.18.1 completion guard, reasoning cap option, request-builder scope
QA F1 (REJECT-P1 on 2bdc763, qa-reviews/prautoblogger/2026-06-11-2bdc763.md):
ARCHITECTURE.md was not updated in the commit that introduced the new
class and option (Standard §7 same-commit rule).

- File tree: add class-open-router-completion-guard.php (empty-completion
  guard, v0.18.1) under includes/providers/.
- Options table: add prautoblogger_reasoning_max_tokens (v0.18.1 reasoning
  token cap; default PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS = 2048;
  0 = legacy uncapped effort mode).
- File tree: refresh class-open-router-request-builder.php description —
  it now owns request body assembly + the reasoning token cap/headroom,
  not just headers.

No version bump: 0.18.1 is still unreleased on this branch.

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 21:06:46 +07:00
peptiderepo
da0250f3ba fix: empty-content guard + finish_reason=length handling + reasoning budget (v0.18.1)
Reasoning models at high effort could spend the entire max_tokens budget
on thinking and return zero visible content with finish_reason=length;
the empty article flowed writer -> editor (reject) -> empty draft post,
with the cost booked as success (2026-06-11 incident: prod run acf24029,
draft post 921; diagnosis: convo pipeline-v2-phase1 seq 12).

- Empty-completion guard at the provider seam (new
  OpenRouter_Completion_Guard): empty/whitespace content is a failure;
  one retry with reasoning disabled (per-call override) when
  finish_reason=length or reasoning was active, then the stage fails.
- finish_reason=length always warning-logged (model, stage, tokens).
- Failed/retried attempts booked to generation_log with
  response_status=error + real token burn; run_decisions verdict rows
  (retry_reasoning_disabled / failed_empty_completion) for v0.18.0 runs.
- Writer-path guard in Content_Generator::execute_stage and
  belt-and-braces empty check in Publisher::create_post.
- Reasoning budget sanity: reasoning.max_tokens cap + completion
  headroom via new prautoblogger_reasoning_max_tokens setting (default
  constant 2048, 0 = legacy uncapped effort mode); cap replaces effort
  when active; governor reservations use the effective ceiling.
- Body assembly moved to Request_Builder::build_body (300-line cap).
- Tests: guard verdict matrix, builder budget shaping, writer/publisher
  guards, full provider retry path.

Agent-Session: prab-empty-draft-guard-20260611
2026-06-11 21:06:46 +07:00
24 changed files with 1824 additions and 491 deletions

View File

@@ -451,7 +451,7 @@ jobs:
if rev.get("concerns"): if rev.get("concerns"):
lines.append("**Concerns:**") lines.append("**Concerns:**")
for c in rev["concerns"]: for c in rev["concerns"]:
sev = c["severity"].upper() sev = c.get("severity", "UNKNOWN").upper()
f = f"`{c['file']}`" if c.get("file") else "" f = f"`{c['file']}`" if c.get("file") else ""
lines.append(f"- **{sev}** {f} — {c['issue']}") lines.append(f"- **{sev}** {f} — {c['issue']}")
lines.append("") lines.append("")

View File

@@ -128,7 +128,8 @@ prautoblogger/
│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution │ ├── 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-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-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration
│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry │ ├── 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-ajax-handlers.php # Non-generation AJAX: images, models, test connections │ ├── class-ajax-handlers.php # Non-generation AJAX: images, models, test connections
│ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation │ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation
│ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron │ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron
@@ -213,6 +214,8 @@ prautoblogger/
│ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver │ │ ├── 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-support.php # Key, response parsing, retry, dimension snap
│ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher │ │ ├── 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) │ │ └── (new providers go here — see CONVENTIONS.md)
│ │ │ │
│ ├── services/ │ ├── services/
@@ -337,11 +340,19 @@ Hostinger's 120-second LiteSpeed connection timeout. The frontend polls for stat
└──────────────────────────────┘ └──────────────────────────────┘
``` ```
**Fallback detection:** If Hostinger kills the PHP process before it updates the **Fallback detection (v0.18.3 R2b/R3):** The status transient is renewed on every stage
transient (despite `ignore_user_abort`), the status endpoint detects the orphan after transition via `update_generation_stage()` (`Pipeline_Status::broadcast()`), so its TTL
180 seconds by checking for recently-created posts with `_prautoblogger_run_id` meta. always counts from the last activity rather than run start. When a status poll finds the
It also releases any stale generation lock. After 600 seconds (STATUS_TTL), it gives transient absent:
up unconditionally and reports a timeout.
- **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.
--- ---
@@ -536,7 +547,7 @@ dropped on uninstall.
| id | BIGINT UNSIGNED | Auto-increment PK | | id | BIGINT UNSIGNED | Auto-increment PK |
| run_id | VARCHAR(36) | Pipeline run UUID | | run_id | VARCHAR(36) | Pipeline run UUID |
| stage | VARCHAR(50) | Stage name (see stage vocabulary) | | stage | VARCHAR(50) | Stage name (see stage vocabulary) |
| agent_role | VARCHAR(50) | Fan-out dimension for Phase-2 quorum ('' default) | | agent_role | VARCHAR(50) | Fan-out dimension. Populated in Phase 1 from `Stage_Display_Map::default_agent_role()` (e.g. 'writer', 'editor', 'publisher'). Phase-2 quorum passes multiple roles for the same stage. |
| item_key | VARCHAR(64) | Scopes article-level stages within multi-article runs ('' for run-level) | | item_key | VARCHAR(64) | Scopes article-level stages within multi-article runs ('' for run-level) |
| status | VARCHAR(20) | pending / running / done / failed / halted | | status | VARCHAR(20) | pending / running / done / failed / halted |
| attempt | SMALLINT UNSIGNED | Re-entry counter | | attempt | SMALLINT UNSIGNED | Re-entry counter |
@@ -831,8 +842,9 @@ multi-step publish paths. Four subsystems:
keyed by `_prautoblogger_run_id` + `_prautoblogger_idea_hash` (check-before-insert) so a 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) 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 marks runs stuck `running` > 2× expected stage wall-clock as `failed`; such runs render
as **"incomplete"** in audit queries. Quorum logic itself is Phase 2 — the schema just as **"incomplete"** in audit queries. Quorum logic (multiple roles per stage) itself is Phase 2. In Phase 1,
reserves its dimensions. `agent_role` is populated on every row from `Stage_Display_Map::default_agent_role()`
so the run timeline is self-describing and the idempotency key is fully populated.
### #20: PHPUnit test infrastructure + WordPress-Core PHPCS compliance (v0.10.1) ### #20: PHPUnit test infrastructure + WordPress-Core PHPCS compliance (v0.10.1)

View File

@@ -5,6 +5,99 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/). and this project uses [Semantic Versioning](https://semver.org/).
## [0.18.3] - 2026-06-12
GA4-context SQL fix, manual-run resilience, and honest status on transient expiry
(CTO thread `2026-06-pipeline-v2-phase1` seq 38).
### Fixed
- **Analysis-prompts SQL / GA4 self-improvement loop (R1 — P2)**: `phpcs:ignore` annotation
was embedded inside the SQL string literal in `class-analysis-prompts.php:81` (same pattern
as the v0.18.2 cost-reporter fix). MariaDB rejected the query on every pipeline run,
causing `get_performance_context()` to always return `''` — the LLM research/analysis
stage has had no historical performance context since v0.16.0. Annotation moved to the
`// phpcs:ignore` pragma above `get_results()`. Sibling occurrence also fixed in
`class-post-assembler.php:112` (same pattern). Regression + behavioral tests added to
`AnalysisPromptsTest`.
- **Manual-run resilience — transient renewal (R2a)**: `update_generation_stage()` in the
new `PRAutoBlogger_Generation_Status_Poller` now renews the STATUS_TTL on every stage
update, ensuring the status transient outlasts any single LLM call even when `Pipeline_
Status::broadcast()` is delayed by a long model call.
- **Manual-run resilience — stuck-run detection (R2b)**: `on_ajax_generation_status()`
now detects when the status transient is absent but the generation lock has been held
longer than STATUS_TTL seconds — indicative of a Hostinger background-process kill.
Marks the stuck run `failed` in the audit ledger, releases the lock, and returns
`status: error` with an "infrastructure timeout" message instead of silently resetting
to idle.
- **Honest status when transient gone but lock held (R3)**: when the status transient is
absent but the lock is held within TTL, `on_ajax_generation_status()` now returns
`status: running` with `started_at` from the lock timestamp (rather than `status: idle`)
so the Generate Now button reflects reality during long background runs.
### Changed
- **Executor split (300-line rule)**: `class-executor.php` was at 351 lines. AJAX generation
handlers (`on_ajax_generate_now`, `on_ajax_generation_status`, `abort_orphaned_run`,
`update_generation_stage`) extracted to new `class-generation-status-poller.php` (260 lines).
Executor retains cron handlers and delegates AJAX calls to the poller. Public API unchanged.
- **Generation_Lock**: new `get_acquired_at()` method returns the Unix timestamp when the
lock was set, used by R2b/R3 to determine lock age without touching the pipeline run table.
### Added
- **Runware catalog `modelSearch` migration**: `class-runware-model-catalog.php` migrated
from the retired `taskType:models` endpoint to the current `taskType:modelSearch` API.
Payload format: `[{taskType:authentication,apiKey:...},{taskType:modelSearch,
taskUUID:<uuid4>,offset:N,limit:N}]`. Response normalization updated for the new shape:
`air` identifier replaces `id`, `capabilities[]` array replaces `taskType`/`requiresImage`
(filter: entry must contain `"io:text-to-image"`), `comment` field maps to `description`.
Logic extracted to `class-runware-catalog-fetcher.php` companion (300-line rule). Cache
option key bumped to `prautoblogger_runware_model_cache_v2` to cleanly invalidate v1 rows.
- **Pagination for `modelSearch`**: fetcher paginates via `offset`/`limit` up to 5 pages of
100 results = 500 models inspected. Runware's catalog has 320k+ entries (mostly community
checkpoints); 5 pages reliably captures all curated text-to-image models. A page-cap
warning is logged if the cap is reached before exhausting `totalResults`. Client-side
`io:text-to-image` capability filter applied — no server-side filter is available.
- **Negative-result failure cooldown**: `sync()` records a failure timestamp in
`prautoblogger_runware_catalog_last_failure_at` on every error path. `get_models()`
gates re-trigger through `is_in_failure_cooldown()`, which respects a configurable
window from `prautoblogger_runware_catalog_failure_cooldown_seconds` (default 3600s / 1h,
never hardcoded at point of use). A successful sync clears the timestamp. This caps the
admin-page-load cascade (42 errors/day observed) to ~24/day even when the cache is absent.
- **Hardcoded fallback list preserved**: unchanged; carried prod through the entire
`taskType:models` outage. Tests verify it is always non-empty and correctly priced.
## [0.18.2] - 2026-06-12
Four confirmed-live maintenance fixes (CTO thread `2026-06-pipeline-v2-phase1` seq 25).
### Fixed
- **Cost-reporter SQL (P2)**: `phpcs:ignore` annotations were embedded inside two SQL string
literals in `class-cost-reporter.php` (`get_daily_spend` and `get_spend_by_stage`). MariaDB
received the comment text as literal SQL and rejected both queries, breaking the Metrics &
Costs admin page. Annotations moved above the `$wpdb->prepare()` calls. Regression tests
added to `CostReporterTest` asserting no `// phpcs:ignore` text appears in the SQL passed
to `prepare()`.
- **Collector failure observability (P2)**: `RuntimeException` catch in
`Source_Collector::collect_from_all_sources()` now logs structured meta — model slug,
HTTP status parsed from the exception message, and the first 200 chars of the error body —
so a 27-day outage like the seq-22 llm_research 404 incident is diagnosable after the fact.
No behaviour change; logging only.
- **`run_stages.agent_role` symmetric write (P3)**: `Pipeline_Runner`, `Article_Worker`, and
`Content_Generator` were calling `start()` with the mapped role but leaving `done()`,
`is_done()`, and `get_output()` with `agent_role = ''`. With `agent_role` in the UNIQUE key,
the mismatch addressed different rows — stranding `start()` rows in `running` forever and
breaking idempotent resume (duplicate-post vector). Fixed via an in-class seam in
`Run_Stage_State::resolve_role()`: when the caller passes `''`, the role is derived from
`Stage_Display_Map::default_agent_role($stage)` before any DB query, ensuring every method
addresses the same row. Explicit non-empty roles (Phase-2 fan-out) are passed through
unchanged. `get()` falls back to `role=''` on miss for mid-run-upgrade compat with v0.18.1
rows. Two new `RunStageStateTest` cases lock the symmetric-role and fallback contracts.
ARCHITECTURE.md §22 updated to document Phase 1 role population.
- **Runware float deprecation (P3)**: `normalize_seed()` in `class-runware-image-support.php`
applied the modulo operator to a float (`microtime(true) * 1000`), triggering
`Deprecated: Implicit conversion from float … to int` on every image generation. Fixed
with `(int) round(...)` before the `%` operator.
## [0.18.1] - 2026-06-11 ## [0.18.1] - 2026-06-11
Empty-draft hotfix (CTO diagnosis: convo thread `2026-06-pipeline-v2-phase1` seq 12; prod run Empty-draft hotfix (CTO diagnosis: convo thread `2026-06-pipeline-v2-phase1` seq 12; prod run

View File

@@ -4,25 +4,26 @@ declare(strict_types=1);
/** /**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard * phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
* *
* Cron/AJAX handlers for content generation, metrics collection, model registry. * Cron handlers for content generation and metrics collection.
* *
* 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. * Triggered by: PRAutoBlogger hook registration.
* Dependencies: Pipeline_Runner, Metrics_Collector, Generation_Lock, Logger. * Dependencies: Pipeline_Runner, Metrics_Collector, Generation_Lock, Logger.
* *
* @see class-prautoblogger.php — Hook wiring. * @see class-generation-status-poller.php — "Generate Now" AJAX + status poll.
* @see core/class-pipeline-runner.php — Actual generation pipeline. * @see class-prautoblogger.php — Hook wiring.
* @see class-generation-lock.php — DB mutex. * @see core/class-pipeline-runner.php — Actual generation pipeline.
* @see class-generation-lock.php — DB mutex.
*/ */
class PRAutoBlogger_Executor { class PRAutoBlogger_Executor {
/** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */ /** @var PRAutoBlogger_OpenRouter_Model_Registry|null Lazy-loaded singleton. */
private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null; private ?PRAutoBlogger_OpenRouter_Model_Registry $model_registry = null;
/** Transient key for background generation status. */ /** @var PRAutoBlogger_Generation_Status_Poller|null Lazy-loaded poller. */
private const STATUS_TRANSIENT = 'prautoblogger_generation_status'; private ?PRAutoBlogger_Generation_Status_Poller $poller = null;
/** How long to keep the result available for polling (seconds). */
private const STATUS_TTL = 600;
/** /**
* Handle the daily generation cron event. * Handle the daily generation cron event.
@@ -58,20 +59,6 @@ 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. * Handle the metrics collection cron event.
* Side effects: API calls to GA4, database writes to ab_content_scores. * Side effects: API calls to GA4, database writes to ab_content_scores.
@@ -89,79 +76,12 @@ class PRAutoBlogger_Executor {
/** /**
* AJAX handler: kick off manual generation as a background cron job. * 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. * Side effects: schedules a WP-Cron event, writes a status transient.
*/ */
public function on_ajax_generate_now(): void { public function on_ajax_generate_now(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' ); $this->poller()->on_ajax_generate_now();
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' ),
)
);
} }
/** /**
@@ -179,18 +99,18 @@ class PRAutoBlogger_Executor {
if ( ! PRAutoBlogger_Generation_Lock::acquire() ) { if ( ! PRAutoBlogger_Generation_Lock::acquire() ) {
set_transient( set_transient(
self::STATUS_TRANSIENT, PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array( array(
'status' => 'error', 'status' => 'error',
'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ), 'message' => __( 'Could not acquire generation lock. Another run may be in progress.', 'prautoblogger' ),
), ),
self::STATUS_TTL PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
); );
return; return;
} }
try { try {
$this->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) ); $this->poller()->update_generation_stage( __( 'Collecting sources from Reddit...', 'prautoblogger' ) );
$runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true ); $runner = ( new PRAutoBlogger_Pipeline_Runner() )->set_skip_dedup( true );
$results = $runner->run(); $results = $runner->run();
@@ -199,7 +119,7 @@ class PRAutoBlogger_Executor {
// When articles ARE queued, the pipeline handles status + lock release. // When articles ARE queued, the pipeline handles status + lock release.
if ( ! get_option( 'prautoblogger_article_queue' ) ) { if ( ! get_option( 'prautoblogger_article_queue' ) ) {
set_transient( set_transient(
self::STATUS_TRANSIENT, PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array( array(
'status' => 'complete', 'status' => 'complete',
'generated' => $results['generated'], 'generated' => $results['generated'],
@@ -207,7 +127,7 @@ class PRAutoBlogger_Executor {
'rejected' => $results['rejected'], 'rejected' => $results['rejected'],
'cost' => $results['cost'], 'cost' => $results['cost'],
), ),
self::STATUS_TTL PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
); );
PRAutoBlogger_Generation_Lock::release(); PRAutoBlogger_Generation_Lock::release();
} }
@@ -217,12 +137,12 @@ class PRAutoBlogger_Executor {
'pipeline' 'pipeline'
); );
set_transient( set_transient(
self::STATUS_TRANSIENT, PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array( array(
'status' => 'error', 'status' => 'error',
'message' => $e->getMessage(), 'message' => $e->getMessage(),
), ),
self::STATUS_TTL PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
); );
$this->mark_current_run_failed(); $this->mark_current_run_failed();
PRAutoBlogger_Generation_Lock::release(); PRAutoBlogger_Generation_Lock::release();
@@ -247,89 +167,44 @@ class PRAutoBlogger_Executor {
delete_option( 'prautoblogger_article_queue' ); delete_option( 'prautoblogger_article_queue' );
PRAutoBlogger_Generation_Lock::release(); PRAutoBlogger_Generation_Lock::release();
set_transient( set_transient(
self::STATUS_TRANSIENT, PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT,
array( array(
'status' => 'error', 'status' => 'error',
'message' => $e->getMessage(), 'message' => $e->getMessage(),
), ),
self::STATUS_TTL PRAutoBlogger_Generation_Status_Poller::STATUS_TTL
); );
} }
} }
/** AJAX: return generation status for frontend polling. Recovers orphaned runs. */ /** AJAX: return generation status for frontend polling. Delegates to poller. */
public function on_ajax_generation_status(): void { public function on_ajax_generation_status(): void {
check_ajax_referer( 'prautoblogger_generate_now', 'nonce' ); $this->poller()->on_ajax_generation_status();
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 );
return;
}
$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,
)
);
} }
/** /**
* Helper: update the generation stage for frontend polling. * Lazy-load the generation status poller.
* *
* @param string $stage Human-readable stage description. * @return PRAutoBlogger_Generation_Status_Poller
*/ */
private function update_generation_stage( string $stage ): void { private function poller(): PRAutoBlogger_Generation_Status_Poller {
$current = get_transient( self::STATUS_TRANSIENT ); if ( null === $this->poller ) {
if ( is_array( $current ) ) { $this->poller = new PRAutoBlogger_Generation_Status_Poller();
$current['stage'] = $stage; }
$current['last_updated'] = time(); return $this->poller;
set_transient( self::STATUS_TRANSIENT, $current, self::STATUS_TTL ); }
/**
* 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' );
} }
} }

View File

@@ -76,6 +76,29 @@ class PRAutoBlogger_Generation_Lock {
return null !== $row; 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. * Release the generation mutex.
* *

View File

@@ -0,0 +1,260 @@
<?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 );
}
}
}

View File

@@ -76,9 +76,9 @@ class PRAutoBlogger_Analysis_Prompts {
global $wpdb; global $wpdb;
$scores_table = $wpdb->prefix . 'prautoblogger_content_scores'; $scores_table = $wpdb->prefix . 'prautoblogger_content_scores';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$top_posts = $wpdb->get_results( $top_posts = $wpdb->get_results(
"SELECT cs.post_id, cs.composite_score, p.post_title // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT cs.post_id, cs.composite_score, p.post_title
FROM {$scores_table} cs FROM {$scores_table} cs
JOIN {$wpdb->posts} p ON p.ID = cs.post_id JOIN {$wpdb->posts} p ON p.ID = cs.post_id
WHERE cs.composite_score > 0 WHERE cs.composite_score > 0

View File

@@ -103,7 +103,7 @@ class PRAutoBlogger_Article_Worker {
$review = $this->review_with_state( $editor, $content, $idea, $run_id, $item ); $review = $this->review_with_state( $editor, $content, $idea, $run_id, $item );
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Saving and publishing…', 'prautoblogger' ) ); PRAutoBlogger_Pipeline_Status::broadcast( __( 'Saving and publishing…', 'prautoblogger' ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', '', $item ); PRAutoBlogger_Run_Stage_State::start( $run_id, 'publish', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'publish' ), $item );
$this->publish_or_draft( $this->publish_or_draft(
$content, $content,
$idea, $idea,
@@ -174,7 +174,7 @@ class PRAutoBlogger_Article_Worker {
return new PRAutoBlogger_Editorial_Review( $data ); return new PRAutoBlogger_Editorial_Review( $data );
} }
} }
PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', '', $item ); PRAutoBlogger_Run_Stage_State::start( $run_id, 'review', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'review' ), $item );
} }
$review = $editor->review( $content, $idea ); $review = $editor->review( $content, $idea );

View File

@@ -194,7 +194,7 @@ class PRAutoBlogger_Content_Generator {
); );
return $cached; return $cached;
} }
PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, '', $this->item_key ); PRAutoBlogger_Run_Stage_State::start( $this->run_id, $stage, (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ), $this->item_key );
} }
// v0.18.1: stage metadata for the provider's empty-completion // v0.18.1: stage metadata for the provider's empty-completion

View File

@@ -65,8 +65,9 @@ class PRAutoBlogger_Cost_Reporter {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_results( $results = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( $wpdb->prepare(
"SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT DATE(created_at) as day, SUM(estimated_cost) as total_cost
FROM {$table} FROM {$table}
WHERE created_at >= %s AND response_status = 'success' WHERE created_at >= %s AND response_status = 'success'
GROUP BY DATE(created_at) GROUP BY DATE(created_at)
@@ -102,11 +103,12 @@ class PRAutoBlogger_Cost_Reporter {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$results = $wpdb->get_results( $results = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->prepare( $wpdb->prepare(
"SELECT stage, "SELECT stage,
SUM(estimated_cost) as total_cost, SUM(estimated_cost) as total_cost,
SUM(prompt_tokens + completion_tokens) as total_tokens, SUM(prompt_tokens + completion_tokens) as total_tokens,
COUNT(*) as call_count // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared COUNT(*) as call_count
FROM {$table} FROM {$table}
WHERE created_at BETWEEN %s AND %s AND response_status = 'success' WHERE created_at BETWEEN %s AND %s AND response_status = 'success'
GROUP BY stage", GROUP BY stage",

View File

@@ -185,14 +185,14 @@ class PRAutoBlogger_Pipeline_Runner {
$source_labels = is_array( $enabled ) ? implode( ', ', $enabled ) : 'reddit'; $source_labels = is_array( $enabled ) ? implode( ', ', $enabled ) : 'reddit';
/* translators: %s is a comma-separated list of enabled source names, e.g. "reddit, llm_research". */ /* 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_Pipeline_Status::broadcast( sprintf( __( 'Collecting sources from %s…', 'prautoblogger' ), $source_labels ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'research' ); PRAutoBlogger_Run_Stage_State::start( $run_id, 'research', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'research' ) );
( new PRAutoBlogger_Source_Collector() ) ( new PRAutoBlogger_Source_Collector() )
->set_cost_tracker( $cost_tracker ) ->set_cost_tracker( $cost_tracker )
->collect_from_all_sources(); ->collect_from_all_sources();
PRAutoBlogger_Run_Stage_State::done( $run_id, 'research' ); PRAutoBlogger_Run_Stage_State::done( $run_id, 'research' );
PRAutoBlogger_Pipeline_Status::broadcast( __( 'Analyzing topics and scoring…', 'prautoblogger' ) ); PRAutoBlogger_Pipeline_Status::broadcast( __( 'Analyzing topics and scoring…', 'prautoblogger' ) );
PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis' ); PRAutoBlogger_Run_Stage_State::start( $run_id, 'analysis', (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( 'analysis' ) );
$llm = new PRAutoBlogger_OpenRouter_Provider(); $llm = new PRAutoBlogger_OpenRouter_Provider();
$analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker ); $analyzer = new PRAutoBlogger_Content_Analyzer( $llm, $cost_tracker );
$analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) ); $analysis = $analyzer->analyze_recent_data( max( $target * 2, 6 ) );

View File

@@ -108,8 +108,8 @@ class PRAutoBlogger_Post_Assembler {
// Find the unlinked research cost row for this run. // Find the unlinked research cost row for this run.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
$research_row = $wpdb->get_row( $research_row = $wpdb->get_row(
$wpdb->prepare( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared "SELECT id, estimated_cost, provider, model, prompt_tokens, completion_tokens
FROM {$table} FROM {$table}
WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL WHERE run_id = %s AND stage = 'llm_research' AND post_id IS NULL
LIMIT 1", LIMIT 1",

View File

@@ -6,40 +6,33 @@ declare(strict_types=1);
* *
* Per-run per-stage state machine over `wp_prautoblogger_run_stages`. * Per-run per-stage state machine over `wp_prautoblogger_run_stages`.
* *
* What: One row per (run_id, stage, agent_role, item_key) holding * One row per (run_id, stage, agent_role, item_key) holding
* pending|running|done|failed|halted plus the stage's output * pending|running|done|failed|halted plus the output snapshot (meta_json).
* snapshot (meta_json). This is what makes resume idempotent on the * Done stages are sticky and never re-charged on resume. Self-healing:
* chained-cron architecture: a `done` stage is NEVER re-run or * a missing table degrades every method to a no-op/false.
* 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.
* *
* @see core/class-run-state.php — Run-level lifecycle + ledger row. * v0.18.2: role is resolved from Stage_Display_Map when caller passes ''
* @see core/class-run-reaper.php — Sweeps stages stuck in 'running'. * so start/done/is_done/get_output are always symmetric. Explicit non-empty
* @see ARCHITECTURE.md #21 — Idempotency-key design. * 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.
*/ */
class PRAutoBlogger_Run_Stage_State { class PRAutoBlogger_Run_Stage_State {
/** @var bool|null Per-request "run_stages table exists" probe result. */ /** @var bool|null Per-request "run_stages table exists" probe result. */
private static ?bool $table_ok = null; private static ?bool $table_ok = null;
/** Fully-qualified run_stages table name. */ /** @return string Fully-qualified run_stages table name. */
public static function table_name(): string { public static function table_name(): string {
global $wpdb; global $wpdb;
return $wpdb->prefix . 'prautoblogger_run_stages'; return $wpdb->prefix . 'prautoblogger_run_stages';
} }
/** /**
* Whether the run_stages table exists and is queryable. Cached per * Whether the run_stages table exists and is queryable. Cached per request.
* request; never throws.
* *
* @return bool * @return bool
*/ */
@@ -49,7 +42,7 @@ class PRAutoBlogger_Run_Stage_State {
} }
global $wpdb; global $wpdb;
if ( null === $wpdb ) { if ( null === $wpdb ) {
return false; // Not cached: $wpdb may appear later in boot. return false;
} }
$table = self::table_name(); $table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -59,18 +52,17 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Stable hash identifying one article idea within a run (post-creation * Stable 16-hex hash for one article idea within a run.
* idempotency + stage item scoping both key off this).
* *
* @param PRAutoBlogger_Article_Idea $idea The idea. * @param PRAutoBlogger_Article_Idea $idea The idea.
* @return string 16-hex-char hash. * @return string
*/ */
public static function idea_hash( PRAutoBlogger_Article_Idea $idea ): string { public static function idea_hash( PRAutoBlogger_Article_Idea $idea ): string {
return substr( md5( $idea->get_suggested_title() . '|' . $idea->get_topic() ), 0, 16 ); return substr( md5( $idea->get_suggested_title() . '|' . $idea->get_topic() ), 0, 16 );
} }
/** /**
* Stage item_key for an article idea ('' is reserved for run-level stages). * Stage item_key for an article idea ('' reserved for run-level stages).
* *
* @param PRAutoBlogger_Article_Idea $idea The idea. * @param PRAutoBlogger_Article_Idea $idea The idea.
* @return string e.g. 'idea:1a2b3c4d5e6f7a8b'. * @return string e.g. 'idea:1a2b3c4d5e6f7a8b'.
@@ -80,22 +72,18 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Mark a stage as entered (running). Upsert: first entry inserts the * Mark a stage as running. Sticky: done stages are never demoted.
* 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 $run_id Run UUID.
* @param string $stage Stage name (see Stage_Display_Map). * @param string $stage Stage name (see Stage_Display_Map).
* @param string $agent_role Fan-out dimension ('' default in Phase 1). * @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope ('' for run-level stages). * @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 { public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! self::is_available() ) { if ( '' === $run_id || ! self::is_available() ) {
return; return;
} }
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb; global $wpdb;
$table = self::table_name(); $table = self::table_name();
$now = current_time( 'mysql' ); $now = current_time( 'mysql' );
@@ -120,22 +108,20 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Mark a stage done, persisting its output for resume-without-recharge. * Mark a stage done and persist its output for resume-without-recharge.
*
* Side effects: one INSERT … ON DUPLICATE KEY UPDATE.
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension. * @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @param string|null $output Stage output to snapshot (pruned after R days). * @param string|null $output Stage output snapshot (pruned after R days).
* @param float $cost_usd Cost attributed to the stage. * @param float $cost_usd Cost attributed to this 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 { 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() ) { if ( '' === $run_id || ! self::is_available() ) {
return; return;
} }
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb; global $wpdb;
$table = self::table_name(); $table = self::table_name();
$now = current_time( 'mysql' ); $now = current_time( 'mysql' );
@@ -166,18 +152,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 $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension. * @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @return void
*/ */
public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void { public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void {
if ( '' === $run_id || ! self::is_available() ) { if ( '' === $run_id || ! self::is_available() ) {
return; return;
} }
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb; global $wpdb;
$table = self::table_name(); $table = self::table_name();
$now = current_time( 'mysql' ); $now = current_time( 'mysql' );
@@ -197,18 +183,20 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Fetch one stage row. * 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).
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension. * @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @return array<string, mixed>|null Row or null. * @return array<string, mixed>|null
*/ */
public static function get( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): ?array { public static function get( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): ?array {
if ( '' === $run_id || ! self::is_available() ) { if ( '' === $run_id || ! self::is_available() ) {
return null; return null;
} }
$agent_role = self::resolve_role( $stage, $agent_role );
global $wpdb; global $wpdb;
$table = self::table_name(); $table = self::table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
@@ -222,7 +210,25 @@ class PRAutoBlogger_Run_Stage_State {
), ),
ARRAY_A ARRAY_A
); );
return is_array( $row ) ? $row : null; 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;
} }
/** /**
@@ -230,7 +236,7 @@ class PRAutoBlogger_Run_Stage_State {
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension. * @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @return bool * @return bool
*/ */
@@ -244,7 +250,7 @@ class PRAutoBlogger_Run_Stage_State {
* *
* @param string $run_id Run UUID. * @param string $run_id Run UUID.
* @param string $stage Stage name. * @param string $stage Stage name.
* @param string $agent_role Fan-out dimension. * @param string $agent_role Fan-out dimension ('' = resolve from stage map).
* @param string $item_key Article scope. * @param string $item_key Article scope.
* @return string|null * @return string|null
*/ */
@@ -258,13 +264,10 @@ class PRAutoBlogger_Run_Stage_State {
} }
/** /**
* Fail every still-open (pending/running) stage of one item — used by * Fail every still-open stage of one item. Done stages are untouched.
* 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 $run_id Run UUID.
* @param string $item_key Stage item key ('' = run-level stages). * @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 { public static function fail_open_for_item( string $run_id, string $item_key ): void {
if ( '' === $run_id || ! self::is_available() ) { if ( '' === $run_id || ! self::is_available() ) {
@@ -290,4 +293,21 @@ class PRAutoBlogger_Run_Stage_State {
public static function flush_cache(): void { public static function flush_cache(): void {
self::$table_ok = null; 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 );
}
} }

View File

@@ -76,8 +76,33 @@ class PRAutoBlogger_Source_Collector {
'collector' 'collector'
); );
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
PRAutoBlogger_Logger::instance()->error( sprintf( 'Collection %s from %s: %s', get_class( $e ), $source_type, $e->getMessage() ), 'collector' ); // Structured failure meta: model slug, HTTP status (parsed from message),
// Continue with other sources — one failure shouldn't stop everything. // 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.
} }
} }

View File

@@ -0,0 +1,211 @@
<?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,
);
}
}

View File

@@ -181,7 +181,8 @@ class PRAutoBlogger_Runware_Image_Support {
*/ */
public function normalize_seed( ?int $seed ): int { public function normalize_seed( ?int $seed ): int {
if ( null === $seed || $seed < 1 ) { if ( null === $seed || $seed < 1 ) {
$derived = (int) ( ( microtime( true ) * 1000 ) % 2147483647 ); // Cast to int before modulo — float % int is deprecated in PHP 8 (v0.18.2).
$derived = (int) round( microtime( true ) * 1000 ) % 2147483647;
return $derived > 0 ? $derived : 1; return $derived > 0 ? $derived : 1;
} }
return $seed; return $seed;

View File

@@ -4,18 +4,30 @@ declare(strict_types=1);
/** /**
* phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard * phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard
* *
* Runware model catalog sync — fetches live model list via Runware's /v1 endpoint, * Runware model catalog sync — orchestrates periodic fetching, caching, failure
* filters to text-to-image tasks, caches normalized results, and falls back to * cooldown, and fallback for the Runware model list.
* hardcoded list on API failure.
* *
* What: Syncs Runware's model catalog periodically (daily cron + on-demand AJAX), * What: Syncs Runware's model catalog (daily cron + on-demand AJAX), caches
* caches the results in WP options, and merges pricing data from the pricing * normalized results in WP options (v2 schema key — see below), merges
* class when Runware's API doesn't expose it. * pricing, and enforces a per-failure cooldown to stop admin-page-load
* cascades when the API is broken or the key is misconfigured.
* Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now" * Who triggers it: Daily cron (prautoblogger_sync_runware_models), AJAX "Sync now"
* button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry. * button in PRAdmin, and lazy-load via PRAutoBlogger_Image_Model_Registry.
* Dependencies: PRAutoBlogger_Logger, PRAutoBlogger_Runware_Image_Pricing, * Dependencies: PRAutoBlogger_Runware_Catalog_Fetcher, PRAutoBlogger_Logger,
* PRAutoBlogger_Image_Model_Registry. * 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 class-runware-image-pricing.php — Authoritative pricing source & fallback models.
* @see admin/class-image-model-registry.php — Caller; merges Runware + OpenRouter lists. * @see admin/class-image-model-registry.php — Caller; merges Runware + OpenRouter lists.
* @see class-prautoblogger.php — Registers daily cron + AJAX hook. * @see class-prautoblogger.php — Registers daily cron + AJAX hook.
@@ -23,20 +35,27 @@ declare(strict_types=1);
*/ */
class PRAutoBlogger_Runware_Model_Catalog { class PRAutoBlogger_Runware_Model_Catalog {
private const RUNWARE_API_URL = 'https://api.runware.ai/v1';
private const CACHE_TTL_SECONDS = 86400; private const CACHE_TTL_SECONDS = 86400;
private const HTTP_TIMEOUT_SECONDS = 30;
/** /**
* Fetch the Runware model catalog, normalize to the Image_Model_Registry * Option key for the v2 normalized model cache (air-identifier schema).
* shape, and write to WP options cache. Returns true on success, false on * Bumped from v1 (prautoblogger_runware_model_cache) to force re-sync.
* API failure or parse error. */
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.
* *
* Side effects: makes HTTP request to Runware API, writes WP options.
* Error handling: logs via PRAutoBlogger_Logger, never throws, always * Error handling: logs via PRAutoBlogger_Logger, never throws, always
* returns bool to allow fallback to cached list. * returns bool to allow fallback to cached/hardcoded list.
* *
* @return bool True on successful fetch and cache update; false on API or parse error. * @return bool True on successful fetch and cache update; false otherwise.
*/ */
public function sync(): bool { public function sync(): bool {
try { try {
@@ -49,16 +68,19 @@ class PRAutoBlogger_Runware_Model_Catalog {
return false; return false;
} }
$raw_models = $this->fetch_models_from_api( $api_key ); $fetcher = new PRAutoBlogger_Runware_Catalog_Fetcher();
$raw_models = $fetcher->fetch_all_text_to_image( $api_key );
if ( empty( $raw_models ) ) { if ( empty( $raw_models ) ) {
PRAutoBlogger_Logger::instance()->warning( PRAutoBlogger_Logger::instance()->warning(
'Runware model catalog fetch returned empty list. Using cached/fallback models.', 'Runware model catalog fetch returned empty list. Using cached/fallback models.',
'runware-catalog' 'runware-catalog'
); );
$this->record_failure();
return false; return false;
} }
$normalized = $this->normalize_models( $raw_models ); $normalized = $fetcher->normalize_models( $raw_models );
$with_pricing = $this->merge_pricing( $normalized ); $with_pricing = $this->merge_pricing( $normalized );
if ( empty( $with_pricing ) ) { if ( empty( $with_pricing ) ) {
@@ -66,11 +88,13 @@ class PRAutoBlogger_Runware_Model_Catalog {
'Runware model catalog normalization resulted in empty list. Using cached/fallback models.', 'Runware model catalog normalization resulted in empty list. Using cached/fallback models.',
'runware-catalog' 'runware-catalog'
); );
$this->record_failure();
return false; return false;
} }
update_option( 'prautoblogger_runware_model_cache', $with_pricing, false ); update_option( self::CACHE_OPTION, $with_pricing, false );
update_option( 'prautoblogger_runware_model_cache_updated_at', time(), false ); update_option( self::CACHE_TS_OPTION, time(), false );
delete_option( self::FAILURE_TS_OPTION );
PRAutoBlogger_Logger::instance()->info( PRAutoBlogger_Logger::instance()->info(
sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ), sprintf( 'Runware model catalog synced: %d models cached.', count( $with_pricing ) ),
@@ -82,38 +106,37 @@ class PRAutoBlogger_Runware_Model_Catalog {
sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ), sprintf( 'Runware model catalog sync failed: %s (%s)', $e->getMessage(), get_class( $e ) ),
'runware-catalog' 'runware-catalog'
); );
$this->record_failure();
return false; return false;
} }
} }
/** /**
* Get the normalized model list, with smart caching logic: * Return the normalized model list, using smart cache + fallback logic:
* - If cache is fresh (< 24h), return it. * fresh cache → cached; stale + not in cooldown → sync; stale cache fallback;
* - If cache is stale or absent, trigger a sync. * no cache → hardcoded fallback (never empty).
* - 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. * @return array<int, array<string, mixed>> Normalized model list.
*/ */
public function get_models(): array { public function get_models(): array {
if ( ! $this->is_stale() ) { if ( ! $this->is_stale() ) {
$cached = get_option( 'prautoblogger_runware_model_cache', array() ); $cached = get_option( self::CACHE_OPTION, array() );
if ( is_array( $cached ) && ! empty( $cached ) ) { if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached; return $cached;
} }
} }
if ( $this->sync() ) { if ( ! $this->is_in_failure_cooldown() && $this->sync() ) {
$cached = get_option( 'prautoblogger_runware_model_cache', array() ); $cached = get_option( self::CACHE_OPTION, array() );
if ( is_array( $cached ) && ! empty( $cached ) ) { if ( is_array( $cached ) && ! empty( $cached ) ) {
return $cached; return $cached;
} }
} }
$cached = get_option( 'prautoblogger_runware_model_cache', array() ); $cached = get_option( self::CACHE_OPTION, array() );
if ( is_array( $cached ) && ! empty( $cached ) ) { if ( is_array( $cached ) && ! empty( $cached ) ) {
PRAutoBlogger_Logger::instance()->info( PRAutoBlogger_Logger::instance()->info(
'Using stale Runware model cache (live sync failed).', 'Using stale Runware model cache (live sync failed or suppressed by cooldown).',
'runware-catalog' 'runware-catalog'
); );
return $cached; return $cached;
@@ -131,12 +154,12 @@ class PRAutoBlogger_Runware_Model_Catalog {
} }
/** /**
* Get the Unix timestamp of the last successful sync, or null if never synced. * Unix timestamp of the last successful sync, or null if never synced.
* *
* @return int|null Unix timestamp, or null if never synced. * @return int|null
*/ */
public function get_last_synced_at(): ?int { public function get_last_synced_at(): ?int {
$ts = get_option( 'prautoblogger_runware_model_cache_updated_at', null ); $ts = get_option( self::CACHE_TS_OPTION, null );
if ( null === $ts ) { if ( null === $ts ) {
return null; return null;
} }
@@ -145,9 +168,23 @@ class PRAutoBlogger_Runware_Model_Catalog {
} }
/** /**
* Check if the cache is stale (older than 24h) or absent. * Unix timestamp of the last sync failure, or null if no failure on record.
* *
* @return bool True if cache should be refreshed; false if still fresh. * @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.
*/ */
public function is_stale(): bool { public function is_stale(): bool {
$updated_at = $this->get_last_synced_at(); $updated_at = $this->get_last_synced_at();
@@ -158,125 +195,53 @@ class PRAutoBlogger_Runware_Model_Catalog {
} }
/** /**
* Fetch the raw model list from Runware's /v1 endpoint via a models task. * Whether a sync attempt should be suppressed due to a recent failure.
* *
* @param string $api_key Decrypted Runware API key. * The cooldown window is read from prautoblogger_runware_catalog_failure_cooldown_seconds
* @return array Raw model list (may be empty). * (default 3600s). This prevents admin-page-load cascades (40+ errors/day)
* @throws \RuntimeException On HTTP error or malformed response. * 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.
*/ */
private function fetch_models_from_api( string $api_key ): array { public function is_in_failure_cooldown(): bool {
$body = wp_json_encode( $last_failure = $this->get_last_failure_at();
array( if ( null === $last_failure ) {
array( return false;
'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 );
$status = (int) wp_remote_retrieve_response_code( $response ); if ( $cooldown <= 0 ) {
if ( 200 !== $status ) { $cooldown = self::COOLDOWN_DEFAULT;
$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();
} }
/** /**
* Normalize raw Runware model objects to the PRAutoBlogger_Image_Model_Registry * Merge pricing from PRAutoBlogger_Runware_Image_Pricing into normalized models.
* shape: id, name, provider, cost_per_image, capabilities, description. * Models without a pricing entry get cost_per_image=null.
*
* 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. * @param array<int, array<string, mixed>> $normalized Normalized models.
* @return array<int, array<string, mixed>> Models with pricing merged. * @return array<int, array<string, mixed>> Models with pricing merged.
*/ */
private function merge_pricing( array $normalized ): array { private function merge_pricing( array $normalized ): array {
$pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs(); $pricing_table = PRAutoBlogger_Runware_Image_Pricing::get_model_costs();
foreach ( $normalized as &$model ) { foreach ( $normalized as &$model ) {
$model_id = $model['id'] ?? ''; $model_id = $model['id'] ?? '';
if ( isset( $pricing_table[ $model_id ] ) ) { if ( isset( $pricing_table[ $model_id ] ) ) {
$model['cost_per_image'] = (float) $pricing_table[ $model_id ]; $model['cost_per_image'] = (float) $pricing_table[ $model_id ];
} }
} }
return $normalized; 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 );
}
/** /**
* Get the decrypted Runware API key from settings. * Get the decrypted Runware API key from settings.
* *
@@ -287,12 +252,10 @@ class PRAutoBlogger_Runware_Model_Catalog {
if ( '' === $stored ) { if ( '' === $stored ) {
return ''; return '';
} }
if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) { if ( PRAutoBlogger_Encryption::is_encrypted( $stored ) ) {
$decrypted = PRAutoBlogger_Encryption::decrypt( $stored ); $decrypted = PRAutoBlogger_Encryption::decrypt( $stored );
return '' === $decrypted ? '' : $decrypted; return '' === $decrypted ? '' : $decrypted;
} }
return $stored; return $stored;
} }
} }

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger * Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/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. * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.18.1 * Version: 0.18.3
* Requires at least: 6.0 * Requires at least: 6.0
* Requires PHP: 7.4 * Requires PHP: 7.4
* Author: PeptideRepo * Author: PeptideRepo
@@ -35,13 +35,18 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions, | Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings. | and limits without magic strings.
*/ */
define( 'PRAUTOBLOGGER_VERSION', '0.18.1' ); define( 'PRAUTOBLOGGER_VERSION', '0.18.3' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.2.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
// API and retry limits — named constants instead of magic numbers. // API and retry limits — named constants instead of magic numbers.
// SAFETY INVARIANT: API_TIMEOUT_SECONDS × MAX_RETRIES must stay < STATUS_TTL (600s,
// defined in PRAutoBlogger_Generation_Status_Poller). The current product
// (180s × 3 = 540s) leaves a 60s margin. If either constant is raised, verify the
// inequality still holds — otherwise a fully-retrying LLM call can exhaust the
// status transient TTL within a single stage and trigger a false orphan-run abort.
define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 ); define( 'PRAUTOBLOGGER_MAX_RETRIES', 3 );
// Pipeline v2 Phase 1 (v0.18.0) defaults. Both are SETTINGS-backed at every // Pipeline v2 Phase 1 (v0.18.0) defaults. Both are SETTINGS-backed at every
// read site (prautoblogger_per_run_cost_ceiling_usd / // read site (prautoblogger_per_run_cost_ceiling_usd /

View File

@@ -0,0 +1,131 @@
{
"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"
}
]
}
]
}

View File

@@ -0,0 +1,126 @@
<?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' );
}
}

View File

@@ -90,4 +90,60 @@ class CostReporterTest extends BaseTestCase {
$this->assertIsFloat( $utilization ); $this->assertIsFloat( $utilization );
$this->assertGreaterThanOrEqual( 0.0, $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'
);
}
} }

View File

@@ -0,0 +1,226 @@
<?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'] );
}
}

View File

@@ -137,4 +137,76 @@ class RunStageStateTest extends BaseTestCase {
$this->assertFalse( \PRAutoBlogger_Run_Stage_State::is_done( 'run-1', 'draft' ) ); $this->assertFalse( \PRAutoBlogger_Run_Stage_State::is_done( 'run-1', 'draft' ) );
$this->assertNull( \PRAutoBlogger_Run_Stage_State::get_output( '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'
);
}
} }

View File

@@ -2,7 +2,9 @@
/** /**
* Tests for PRAutoBlogger_Runware_Model_Catalog. * Tests for PRAutoBlogger_Runware_Model_Catalog.
* *
* Validates live sync, fallback logic, cache staleness, and pricing merge. * 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.
* *
* @package PRAutoBlogger\Tests\Providers * @package PRAutoBlogger\Tests\Providers
*/ */
@@ -11,47 +13,267 @@ namespace PRAutoBlogger\Tests\Providers;
use PRAutoBlogger\Tests\BaseTestCase; use PRAutoBlogger\Tests\BaseTestCase;
use Brain\Monkey\Functions; use Brain\Monkey\Functions;
use Brain\Monkey\Actions;
class RunwareModelCatalogTest extends BaseTestCase { class RunwareModelCatalogTest extends BaseTestCase {
/** Shared in-memory option store reset per test. */
private array $options = array();
protected function setUp(): void { protected function setUp(): void {
parent::setUp(); parent::setUp();
// Mock WP options for cache simulation. $this->options = array();
Functions\when( 'get_option' )->alias( function ( $key, $default = false ) { $this->stub_options();
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
} }
/** Sync with empty or missing API key returns false. */ // ── Helpers ────────────────────────────────────────────────────────────────
public function test_sync_no_api_key(): void {
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 {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog(); $catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync(); $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();
$this->assertFalse( $result ); $this->assertFalse( $result );
$this->assertNotNull( $catalog->get_last_failure_at(), 'Failure timestamp should be recorded' );
} }
/** get_models returns fallback if no cache and sync fails. */ /** After failure, get_models suppresses sync within cooldown window. */
public function test_get_models_fallback_on_sync_failure(): void { 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();
$catalog = new \PRAutoBlogger_Runware_Model_Catalog(); $catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models(); $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();
// Should have the fallback list (15 Runware models).
$this->assertIsArray( $models ); $this->assertIsArray( $models );
$this->assertGreaterThan( 0, count( $models ) ); $this->assertNotEmpty( $models );
// Check at least one expected fallback model exists.
$schnell_exists = false; $schnell_exists = false;
foreach ( $models as $model ) { foreach ( $models as $model ) {
if ( 'runware:100@1' === ( $model['id'] ?? '' ) ) { if ( 'runware:100@1' === ( $model['id'] ?? '' ) ) {
$schnell_exists = true; $schnell_exists = true;
$this->assertSame( 'FLUX.1 schnell (Runware)', $model['name'] );
$this->assertSame( 'runware', $model['provider'] ); $this->assertSame( 'runware', $model['provider'] );
break; break;
} }
@@ -59,28 +281,12 @@ class RunwareModelCatalogTest extends BaseTestCase {
$this->assertTrue( $schnell_exists, 'Fallback should include FLUX.1 schnell' ); $this->assertTrue( $schnell_exists, 'Fallback should include FLUX.1 schnell' );
} }
/** is_stale returns true if never synced. */ /** Fallback list is never empty (even with no key configured). */
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 { public function test_get_models_never_empty(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog(); $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 ); $this->assertNotEmpty( $models );
// All models should have required fields.
foreach ( $models as $model ) { foreach ( $models as $model ) {
$this->assertArrayHasKey( 'id', $model ); $this->assertArrayHasKey( 'id', $model );
$this->assertArrayHasKey( 'name', $model ); $this->assertArrayHasKey( 'name', $model );
@@ -89,13 +295,24 @@ class RunwareModelCatalogTest extends BaseTestCase {
} }
} }
/** Fallback models have correct pricing. */ /** 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. */
public function test_fallback_models_have_pricing(): void { public function test_fallback_models_have_pricing(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog(); $catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models(); $models = $catalog->get_models();
$schnell = null; $schnell = null;
$dev = null; $dev = null;
foreach ( $models as $model ) { foreach ( $models as $model ) {
if ( 'runware:100@1' === $model['id'] ) { if ( 'runware:100@1' === $model['id'] ) {
$schnell = $model; $schnell = $model;
@@ -107,83 +324,98 @@ class RunwareModelCatalogTest extends BaseTestCase {
$this->assertNotNull( $schnell ); $this->assertNotNull( $schnell );
$this->assertEqualsWithDelta( 0.0006, $schnell['cost_per_image'], 0.000001 ); $this->assertEqualsWithDelta( 0.0006, $schnell['cost_per_image'], 0.000001 );
$this->assertNotNull( $dev ); $this->assertNotNull( $dev );
$this->assertEqualsWithDelta( 0.02, $dev['cost_per_image'], 0.000001 ); $this->assertEqualsWithDelta( 0.02, $dev['cost_per_image'], 0.000001 );
} }
/** Fallback models all have 'image_generation' capability. */ // ── Happy path (sync → cache → get_models) ───────────────────────────────
public function test_fallback_models_have_capabilities(): void {
$catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$models = $catalog->get_models();
foreach ( $models as $model ) { /** Successful sync caches models; get_models returns them from cache. */
$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 { public function test_sync_success_caches_and_returns_models(): void {
// The mocked API response body — models must have taskType=imageInference $fixture_body = file_get_contents( __DIR__ . '/../../fixtures/runware-model-search-page1.json' );
// so normalize_models() doesn't filter them out. $this->options['prautoblogger_runware_api_key'] = 'test-api-key-plain';
$fake_body = json_encode(
array( $this->stub_http_helpers();
'data' => array( Functions\when( 'wp_remote_post' )->justReturn(
array( $this->make_http_response( 200, $fixture_body )
'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(); $catalog = new \PRAutoBlogger_Runware_Model_Catalog();
$result = $catalog->sync(); $result = $catalog->sync();
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid models' );
$this->assertTrue( $result, 'Sync should return true on HTTP 200 with valid fixture' );
// get_models() should now return the synced list from cache.
$models = $catalog->get_models(); $models = $catalog->get_models();
$this->assertIsArray( $models ); $this->assertIsArray( $models );
$this->assertCount( 2, $models ); // Fixture has 3 t2i models (schnell, dev, klein 9B).
$this->assertSame( 'test-model-1', $models[0]['id'] ); $this->assertCount( 3, $models, 'get_models should return the 3 cached t2i models' );
$this->assertSame( 'test-model-2', $models[1]['id'] );
$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() );
} }
} }