CEO ask #5 complete: browsable paginated run list + full input/output inspection
per pipeline stage.
## What was built
### Generations list (prautoblogger-gen-history)
Hidden admin page (options.php-parent, priority 14) showing all pipeline runs newest
first, 20 per page. Each row: article title (linked to dossier when a post exists),
start date, status chip, distinct model(s), settled cost, duration. Queries bounded
(LIMIT/OFFSET) — no unbounded SELECT *.
New files:
- includes/admin/class-gen-history-query.php — paginated run list + per-run I/O queries
- includes/admin/class-gen-history-page.php — hidden admin page + asset enqueue
- assets/css/gen-history.css, assets/js/gen-history.js — table + drill-down UI
- templates/admin/gen-history-page.php — HTML template
### Per-step input + output drill-down (Stage I/O)
'Stage I/O' toggle opens an AJAX-loaded inline panel. For each generation_log stage:
- input_system: system-role message content from generation_log.request_json
- input_user: last user-role message from request_json (rendered instruction)
- output: model response from run_stages.meta_json.output
- output_pruned: true when meta_json present but output key absent (retention prune)
New AJAX endpoint: prautoblogger_gen_run_io (manage_options + nonce)
New file: includes/ajax/class-gen-run-io-handler.php
### gen-log INPUT/OUTPUT coverage verified
- INPUT: generation_log.request_json stores the chat body WITHOUT the Authorization
header (Request_Recorder captures body after build_body(), before headers are added).
System + user message content extracted and surfaced.
- OUTPUT: run_stages.meta_json.output is the LLM response text (Run_Stage_State::done()).
Image/log-only stages (image_a, image_b, llm_research, image_prompt_rewrite) have no
run_stages row; output is null, NOT pruned. Pruned outputs are flagged honestly.
### Dossier as entry point
Runs with a linked post show a 'Dossier' button deep-linking into the existing dossier
(which renders full per-stage I/O with editorial context). Stage I/O is complementary —
works for failed/orphan runs and provides the same data in a lighter inline UI.
### Stage-list-driven
Stage labels via Stage_Display_Map::label() — Phase 2b curate/seo appear automatically.
### Dead prautoblogger_pipeline_history endpoint
Not dead — it IS used by pipeline-settings.js for prompt version history (M3 feature).
Left in place. No dangling endpoint.
## M3 P2 sweep
- Removed dead data-preview-nonce and data-diff-nonce HTML attrs from
pipeline-settings-prompt-panel.php (JS uses prabPipeline.*Nonce from
wp_localize_script, not data-* attrs — confirmed by grep).
- Removed 3 redundant wp_create_nonce() calls from class-pipeline-settings-renderer.php
(preview_nonce, history_nonce, diff_nonce keys that fed the now-removed dead attrs).
- Updated CONTEXT.md: added M3 + M4 terminology tables.
- Updated ARCHITECTURE.md: added ajax/ section to file tree; M3/M4 design notes.
- Updated INFORMATION-ARCHITECTURE.md: prautoblogger-gen-history entry.
## Security
- manage_options cap + prautoblogger_gen_run_io nonce on the AJAX endpoint.
- All output esc_html()/esc_url() server-side before JSON-encoding.
- JS renders prompt/response text via textContent — never innerHTML.
- No writes from history UI; request_json never contains auth headers.
## Version: v0.25.0 → v0.26.0
Agent-Session: pipeline-settings-m4
Relocates Content/AI Models/Sources fields into the per-step Pipeline panels; adds a Global Content Context block; retires the 3 duplicate Settings tabs. Option keys unchanged (no migration). QA APPROVE for 80d8e5f; CI green.
Agent-Session: pipeline-settings-m2-merge
Board submenu now registers at admin_menu priority 11 (parent toplevel at 10)
so the page hook lands under prautoblogger_page_prautoblogger-board; $submenu
reordered defensively to keep Board the first item / top-level click target.
Enqueue gate now matches; board.css/board.js load for the first time on prod.
Regression test: BoardMenuRegistrationTest (fails on pre-fix main).
QA: APPROVE-W-P2 qa-reviews/prautoblogger/2026-06-12-6168520.md
Thread: convo/prautoblogger/threads/2026-06-pipeline-v2-phase2-admin/ seq 11-14
Agent-Session: cto-board-404-hotfix-merge-20260612
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
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
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
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
* Commit 1: editorial image prompt + style-template setting
Pivot the image pipeline from the single-panel comic style to a text-free
editorial scientific illustration, and replace the Style Suffix setting with
an editable Style Template setting. Commit 1 of the in-plugin editorial image
pipeline (no composer yet; pipeline otherwise unchanged).
- New constant PRAUTOBLOGGER_DEFAULT_IMAGE_STYLE_TEMPLATE (brief §7 editorial
template with the single {{ topic_summary }} token).
- New setting prautoblogger_image_style_template (textarea) replaces the
Style Suffix field; single-token validation on save (brief A5); old
prautoblogger_image_style_suffix mirrored to _deprecated for one cycle and
not deleted (one-time migration prautoblogger_migrated_style_template_v0160).
- REWRITER_SYSTEM_PROMPT now emits a 1-2 sentence editorial topic/mechanism
summary as the SCENE (no comic gag), keeping the CAPTION line for the
unchanged HTML-caption-below-image path; scene/caption parsing contract
preserved.
- New PRAutoBlogger_Image_Template_Filler substitutes the scene into the
template's {{ topic_summary }} slot across all three entry points; strips
control chars + clamps length (A5); degrades safely on missing token or
empty summary so a blank/token-only prompt is never sent to the provider
(A5/A6).
- Version bump 0.15.1 -> 0.16.0 + CHANGELOG + ARCHITECTURE.md updates.
Agent-Session: image-editorial-commit1
* style: auto-fix PHPCS violations via phpcbf
---------
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Replace hardcoded model registry with live-sync system.
New PRAutoBlogger_Runware_Model_Catalog class fetches the Runware model
catalog daily, caches in WP options (24h TTL), and falls back to the
hardcoded 15-model list if the API is unavailable. Sync Now button
in PRAdmin for on-demand refresh. Eliminates the drift that caused
silent model fallback bugs (v0.13.8, v0.14.0).
All Runware model selections except schnell and dev were silently falling
back to runware:100@1 because COST_PER_IMAGE only had 2 entries while the
image model registry was expanded to 15 models in v0.13.8.
Fix: add all 15 model IDs to COST_PER_IMAGE and DEFAULT_STEPS.
applySort() always read input_price_per_m for price sorting, but image
models carry cost_per_image instead — input_price_per_m is undefined on
them, so every image model got an effective price of 0 and the sort was a
no-op.
Fix: detect image models via cost_per_image !== undefined and use that
field when present; fall back to input_price_per_m for text models.
Files changed:
- assets/js/model-picker.js — applySort price branch
- prautoblogger.php — version bump 0.13.8 → 0.13.9
- CHANGELOG.md — release notes
35% title + 255px fixed PRAB cols left ~50px per auto column.
Replace with a balanced budget across all columns so Author,
Categories, Tags, and Date all get enough room to render horizontally:
title 22%, author 6%, categories 7%, tags 9%, date 8%,
writing_model/image_model 85px each, cost 50px, peptide_topics 75px
table-layout: fixed ignores min-width — the WP posts list table uses
fixed layout so min-width: 80px had no effect. Changed to width: 80px
which is honoured under fixed-layout tables.
Two bugs in class-post-list-columns.php:
1. Cost always showed $0.00: a PHP '// phpcs:ignore' comment was
placed inside the SQL string literal, causing MariaDB syntax error
on every render_cost_cell() query. Moved comment to the line above
wpdb->prepare() where it belongs.
2. Peptide Topics column header compressed to near-zero width by the
three fixed-width PRAB columns (Writing Model + Image Model + Cost =
255px). Added min-width: 80px to .column-taxonomy-peptide_topic so
the header renders horizontally.
Root cause: .ab-mp-price and .ab-mp-ctx were siblings in .ab-mp-row-meta
with price first. Description text is variable-length, so the meta div
grew/shrank per row, shifting the price left/right.
Fix:
- Move price to last child in .ab-mp-row-meta so its right edge is
always pinned to the row's fixed right edge
- Add flex-shrink: 0 to .ab-mp-price so it never compresses
- Cap .ab-mp-ctx at max-width 200px with text-overflow: ellipsis
- Swap column header order (Context, then Price) to match new DOM order
.ab-mp-col-price was missing min-width and right-alignment even though
.ab-mp-price had both. The header sat left while values sat right,
so they didn't track the same column edge. Added min-width: 90px and
justify-content: flex-end to match.
- Add .ab-mp-col-headers bar between search and list with three
clickable column headers: Model (sorts by name), Price in/out
(sorts by input_price_per_m), Context (sorts by context_length)
- Track sortState at module level so preference persists across
open/close; clicking same column toggles asc/desc
- Deprecated models always sink to bottom regardless of sort column
- Add CSS for .ab-mp-col-headers, .ab-mp-col-header, .ab-mp-sort-arrow,
.ab-mp-col-active; widths mirror existing row flex layout so columns
visually align with data
Bumps version 0.13.1 → 0.13.2
Add min-width: 90px and text-align: right to .ab-mp-price so prices
like $0.0006/image and $0.0800/image stack on a consistent right edge
instead of floating at uneven offsets.
class-autoloader.php listed services/ but not services/opik/. All four Opik classes live in that subdirectory. Composer classmap covered it (tests passed); WP runtime autoloader would fatal in production on first Opik class reference. Adds services/opik/ to the scan list. Bumps to v0.12.3.
Opik requires version 7 UUIDs. wp_generate_uuid4() was producing v4
which the API rejected with 422. Replaced both call sites in
class-opik-trace-context.php with generate_uuid7(), a private static
method implementing RFC 9562 §5.7.
Fixes: smoke test 422 {"errors":["Trace id must be a version 7 UUID"]}
File-level sniffs like WordPress.Files.FileName are evaluated at the file level,
not at code level. They require phpcs:ignore directives to be placed within the
file-level docblock (/** ... */), not in code comments.
Moved all 81 phpcs:ignore pragmas from code comments to the file-level docblocks.
This applies to:
- 79 class files with class-naming convention ignores
- prautoblogger.php with strict-types docblock ignore
- uninstall.php with strict-types docblock ignore
- PRAUTOBLOGGER_VERSION constant now matches header (0.10.1)
- CHANGELOG.md updated to accurately reflect Round 3 work:
* OpenRouter test encryption fix (real class round-trip)
* 137 PHPCS violations resolved with targeted ignores + reasons
* Clear accounting of which categories of violations and why ignored
- Added phpcs:ignore for class file naming convention (79 violations)
WordPress.Files.FileName.InvalidClassFileName is a known architectural
issue — class files use short names without prautoblogger- prefix
- Added phpcs:ignore for ->prepare() table interpolation (56 violations)
WordPress.DB.PreparedSQL.InterpolatedNotPrepared: dynamic table names
(prab_generation_logs, prab_cost_logs) require interpolation; ->prepare()
does not support table name placeholders, only column values
- Fixed file-level docblock positioning (2 violations)
prautoblogger.php and uninstall.php: moved declare(strict_types=1) before
file docblock per PHP best practices
Bump version: 0.10.0 → 0.10.1
Updated CHANGELOG.md with Fixed/Changed entries:
- Missing post_type_exists() stub in BaseTestCase
- PHPCS autofix of 1,362 violations across 75 files
- CI gate flipped to strict mode
Updated ARCHITECTURE.md with ADR #20 documenting the test infra
and PHPCS compliance improvements.
Run phpcbf --standard=WordPress-Core across includes/, prautoblogger.php,
and uninstall.php. Fixed 1,362 formatting violations in 75 files:
- Short array syntax: [] → array()
- Line breaking for long function calls
- Consistent indentation
- Function call parameter alignment
No logic changes; purely mechanical formatting.
- prautoblogger.php: plugin header Version + PRAUTOBLOGGER_VERSION
- CHANGELOG.md: Removed/Changed/Note/Cost impact/Migration sections
- ARCHITECTURE.md:
- drop 4 CF provider files from the file tree
- drop 2 CF credential options from the wp_options table
- drop CF Workers AI row from the External API Integrations table
- flip Phase 3 placeholder from CF Workers AI registry to Runware
- supersede ADR #16 with a one-line history note pointing at v0.10.0
- update ADR #17 tail to mention the removal
readme.txt stable tag is stale (0.2.2) and clearly not maintained
inline with real releases — leaving alone in this PR; fix belongs in
a dedicated release-process cleanup.
Wires out the Cloudflare Workers AI image provider across the plugin:
- class-image-pipeline.php: drop 'cloudflare' dispatch branch; Runware
becomes the unrecognised-provider fallback
- class-image-model-registry.php: remove flux-1-schnell/cloudflare row;
update provider-enum docblocks (runware|openrouter only)
- class-settings-fields-extended.php: remove three CF fields
(cloudflare_ai_token, cloudflare_account_id, cf_image_via_gateway);
drop Cloudflare mention from model-picker + NSFW retry descriptions
- class-admin-page.php: remove prautoblogger_cloudflare_ai_token from
the encrypted-options list
- interface-image-provider.php: retarget preamble @see at Runware +
OpenRouter; update get_provider_name() example
- class-image-nsfw-blocked.php: generalise preamble (Runware +
OpenRouter can raise this)
Also cleans stale CF Workers AI mentions in:
class-ajax-handlers.php, interface-model-registry.php,
class-image-nsfw-retry.php, class-open-router-image-pricing.php,
prautoblogger.php.
The Cloudflare AI Gateway path (in class-open-router-config.php,
class-settings-fields.php 'prautoblogger_ai_gateway_base_url',
class-open-router-request-builder.php cache headers) is intentionally
untouched — it fronts OpenRouter and is unrelated to the CF Workers AI
image provider.
New Runware image backend at ~$0.0006/image (FLUX.1 schnell) — 65× cheaper
than Gemini 2.5 Flash Image. Provider mirrors OpenRouter's split: interface
+ provider + support + pricing + batch, all under the 300-line cap. Batch
class uses curl_multi for true parallel A/B dispatch — wall-clock drops to
the slowest single image instead of the sum.
Changes:
- Add PRAutoBlogger_Runware_Image_Provider + Pricing + Support + Batch
- Flip PRAUTOBLOGGER_DEFAULT_IMAGE_PROVIDER/MODEL to runware:100@1
- Add prautoblogger_runware_api_key settings field (encrypted at rest)
- v0.9.0 one-time migration flips legacy default model + re-derives provider
- Wire runware branch into Image_Pipeline::create_default_provider()
- Add runware:100@1 and runware:101@1 to Image_Model_Registry (defaults top)
- Update image prompt builder: no more anthropomorphized molecules;
prefer concrete human-scale scenes (gym bros, doctors, scientists)
- ARCHITECTURE.md + CHANGELOG.md updated with new integration + decision
- Unit tests for pricing, support, provider; update model registry test
Cost impact at 100 images/day (50 articles × A/B):
- Before (Gemini via OpenRouter): $3.90/day ≈ $117/mo
- After (FLUX schnell via Runware): $0.06/day ≈ $1.80/mo
Net saving: ~$115/mo
* feat(images): route Cloudflare Workers AI via AI Gateway by default
Direct probe on 2026-04-21 confirmed the Cloudflare AI Gateway
route to Workers AI (`gateway.ai.cloudflare.com/.../workers-ai/@cf/...`)
is no longer 403ing — the upstream regression noted in v0.8.0's
ARCHITECTURE.md #16 and in thread image-pipeline seq 6 has been
resolved. Routing via the gateway gives us the same benefits we
already get for OpenRouter LLM calls: response caching, cost/latency
dashboard, rate limiting.
Changes:
- `Cloudflare_Image_Support::build_endpoint_url()` now prefers the
gateway URL derived from the existing `prautoblogger_ai_gateway_base_url`
option. Direct API remains the fallback.
- New private helper `build_gateway_workers_ai_url()` strips the
OpenRouter path suffix from the configured gateway URL and appends
`/workers-ai/{model}`. Returns empty string (fallback to direct) when:
no gateway URL configured, the new toggle is off, or the URL isn't a
valid `https://gateway.ai.cloudflare.com/...` shape.
- New admin toggle `prautoblogger_cf_image_via_gateway` (default on) in
the Images section. Operators can disable to force direct API without
clearing the LLM-path gateway URL.
- ARCHITECTURE.md #16 rewritten to reflect the v0.8.2+ gateway routing.
- CHANGELOG.md v0.8.2 entry under Changed.
Tests (new `CloudflareImageSupportTest`):
- Direct URL when gateway not configured.
- Gateway URL derived correctly when configured + toggle on.
- Toggle off forces direct URL even with gateway configured.
- Malformed gateway URL falls back to direct (defensive).
- `is_nsfw_error()` only matches error code 3030 (pinned separately so
a tolerance regression fails early).
Cost impact: near-zero. Gateway adds a few ms of latency and enables
response caching — could *reduce* cost on identical prompts within the
configurable cache TTL.
Uninstall: new option covered by existing prautoblogger_% LIKE sweep.
Implements Commit 1b (narrow AI-Gateway-migration scope) per thread
image-pipeline seq 7.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(cost): seed new toggles in activator defaults; tighten URL stripper
QA review on cd45ae2 surfaced two things; one real, one hallucinated:
- **Real (fixed):** `prautoblogger_cf_image_via_gateway` and v0.8.1's
`prautoblogger_image_nsfw_retry` weren't seeded in
`Activator::set_default_options()`. Belt-and-braces — the `get_option()`
fallback at the read site already defaults to '1', so no user-visible
regression exists, but seeding the options follows the established
pattern for every other plugin setting and makes intent legible to
operators inspecting `wp_options` directly.
- **Hallucinated (rejected):** the reviewer claimed
`CloudflareImageSupportTest` methods 2–5 were empty stubs. Verified
— every test has real `$this->assert*` calls (10 total across the 5
cases). Ignoring. Flagging the false-positive to the CTO per the
qa-review quality-tracking protocol.
Also clarified the docblock + tightened the ternary inside
`build_gateway_workers_ai_url()` — explicit `strrpos` false-check
instead of the `?:` short-circuit, so a no-slash base (defensive edge)
falls through cleanly instead of returning a weirdly-sliced URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(schedule): interpret `prautoblogger_schedule_time` in site timezone
Per thread scheduled-gen-timezone seq 1: WordPress core calls
`date_default_timezone_set('UTC')` early in bootstrap, so the activator's
`strtotime("tomorrow HH:MM")` was resolving in UTC regardless of the
site's configured timezone. On peptiderepo.com (Asia/Singapore, UTC+8)
a `schedule_time` of `06:00` fired at 14:00 local.
Fold this into the existing v0.8.2 branch (PR #102) since both this fix
and the earlier CF-image-gateway change target the same version and
touch overlapping files (activator, settings-fields-extended).
Changes:
- `Activator::next_daily_generation_timestamp()` — new public helper
that reads `prautoblogger_schedule_time`, constructs a
`DateTimeImmutable('tomorrow', wp_timezone())`, applies the
configured hour/minute, returns a Unix timestamp. Defensive fallback
to the old UTC-interpretation behaviour on any DateTime parse error
so a bad tz string can't brick the schedule.
- `Activator::schedule_cron()` delegates to the helper instead of
calling `strtotime` directly.
- New `Activator::reschedule_daily_in_site_timezone_v082()` — one-shot
migration that clears the stale UTC-scheduled event and re-queues
using the helper. Gated by `prautoblogger_migrated_schedule_tz_v082`
option so it runs exactly once. Logs an INFO line comparing
before/after next_run for auditability.
- `class-prautoblogger.php::on_check_db_version()` invokes the
migration on every admin_init; the one-shot flag handles re-run
guarding (no DB_VERSION bump needed — the migration is independent
of the schema version).
- Settings description for "Generation Time" now says
"...in your site's configured timezone (Settings → General →
Timezone). Current site timezone: <wp_timezone_string()>" instead
of the ambiguous "server timezone" phrasing.
File-size compliance:
- Adding the timezone helpers pushed `class-activator.php` to 352
lines. Extracted the 104-line `create_tables()` SQL to a new
`class-schema-installer.php` (136 lines). Activator now 245 lines,
schema file fully dedicated to dbDelta.
Tests (new):
- `ActivatorScheduleTest` — SGT 06:00 → 22:00 UTC prev day; UTC 06:00
→ 06:00 UTC (no-op case); Asia/Kathmandu UTC+5:45 → 00:15 UTC
(fractional offset); malformed time input doesn't throw.
- `ActivatorMigrationTest` — pre-seeded UTC cron gets cleared and
rescheduled in SGT; second pass is idempotent.
CHANGELOG v0.8.2 entry now also covers the timezone fix under Fixed +
the description wording change under Changed.
Implements thread `scheduled-gen-timezone` seq 1 spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cost): persist run_id on generated posts + back-populate for v0.8.1 reaper
The orphan-research-row reaper (upcoming commit) needs a cheap way to
answer "which posts belong to this run_id?" when amortizing a stranded
`llm_research` row back to its siblings. Relying on the generation_log
post_id column is circular: if the pipeline died before amortize_research
ran, we'd trust the same table we're trying to repair.
- `Publisher::build_meta()` now accepts `?string $run_id` and writes
`_prautoblogger_run_id => $run_id` when non-empty. Null/empty preserves
existing null-safe legacy behaviour.
- `Publisher::create_post()` forwards the $run_id it already receives
into build_meta — one-line plumbing, no new parameters at call sites.
- `Activator::backpopulate_run_id_meta_v081()` runs once per site,
gated by `prautoblogger_migrated_run_id_meta_v081` option. Walks
`wp_prautoblogger_generation_log`, pairs (post_id, min(run_id)),
writes `_prautoblogger_run_id` on any post that doesn't already have
it. Uses `metadata_exists()` so it's a no-op on re-run.
- Activator also schedules the new daily `prautoblogger_reap_orphan_research_rows`
cron at 03:15 — 15 min after the primary generation cron — so live
pipelines have time to finish amortizing cleanly before the reaper scans.
- Deactivator unschedules the new hook alongside the existing cron hooks.
No user-visible behaviour change yet. The reaper handler ships in the
next commit; this one just ensures it will have the data + schedule it
needs when it lands.
Part of the v0.8.1 bundle (thread research-cost-reaper).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(cost): daily reaper for orphan llm_research rows
Implements the orphan-research-row reaper per thread research-cost-reaper
seq 1. When a pipeline dies between post creation and
`Post_Assembler::amortize_research_costs()` (Hostinger exec kill, OOM,
LiteSpeed cut, fatal), the shared research row stays in
`wp_prautoblogger_generation_log` with `post_id = NULL` and sibling
articles' cost popovers understate their true spend.
New class `PRAutoBlogger_Research_Reaper` owns:
- `on_cron()` — WP-Cron entrypoint hooked to the daily
`prautoblogger_reap_orphan_research_rows` event (already scheduled
by the Activator in the previous commit at 03:15).
- `reap()` — returns a `{reaped, deleted, skipped}` summary. For each
orphan `llm_research` row older than 1 hour (grace window for live
pipelines):
- `_prautoblogger_run_id` post_meta primary lookup.
- Falls back to distinct post_ids in gen_log with the same run_id.
- If ≥1 post found → delegate to the existing idempotent
`Post_Assembler::amortize_research_costs()`. That method inserts
the per-article rows and deletes the original orphan in one pass,
so the reaper is idempotent on re-run.
- If 0 posts found AND orphan is >7 days old → delete outright as
sunk cost with nothing to attribute to.
- Else → skip; try again the next day.
- Never throws. DB errors caught and logged WARNING.
New WP-CLI command `wp prautoblogger reap-research` registered in the
main loader when `WP_CLI` is defined — manual ops trigger that prints
the same summary.
Departure from the CTO's spec: the reap logic lives on
`Research_Reaper` instead of `Post_Assembler`. Placing it on
Post_Assembler pushed that file to 391 lines, over the 300-line cap.
Moving it to the thin class colocates the attribution logic with the
cron/CLI plumbing that triggers it. `amortize_research_costs()` stays
on Post_Assembler — the reaper delegates to it unchanged. Flagged for
CTO review.
Part of the v0.8.1 bundle (thread research-cost-reaper).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cost): ResearchReaperTest — five cases per spec
Covers the five scenarios from convo thread research-cost-reaper seq 1:
- test_reaps_orphan_with_matching_run_id_posts — meta lookup returns
two post IDs; reaper delegates to amortize_research_costs which
inserts two rows and deletes the orphan.
- test_skips_orphan_within_grace_period — the SELECT's
`created_at < now - 1h` clause filters fresh orphans at the DB; the
reaper's stats stay zero and no side effects fire.
- test_deletes_orphan_with_no_articles_after_7_days — orphan is 8 days
old, zero siblings via meta or gen_log; reaper deletes outright.
- test_falls_back_to_gen_log_when_post_meta_missing — meta returns
empty (legacy pre-v0.8.1 post); gen_log has two distinct post_ids;
amortize still runs.
- test_idempotent_on_repeat_run — second pass on a cleaned state
returns 0/0/0 with no inserts or deletes.
All DB interaction mocked via $wpdb stub — no real DB round-trips.
Fixtures are driven by property mutations in the test methods so
PHPUnit's "one stub per method" restriction on mocks doesn't bite.
Part of the v0.8.1 bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(v0.8.1): version bump + CHANGELOG + ARCHITECTURE.md for reaper
Bump plugin header and PRAUTOBLOGGER_VERSION 0.8.0 → 0.8.1. Patch
bump — the reaper is internal infrastructure; no user-visible UX
change.
CHANGELOG:
- Fixed: orphan `llm_research` rows now reaped daily.
- Added: WP-CLI `wp prautoblogger reap-research`; `_prautoblogger_run_id`
post_meta is now written by Publisher on every post.
ARCHITECTURE.md:
- Decision #13 (Run-ID based log linking) cross-refs the new post_meta.
- New Decision #19 documents the reaper's scan/amortize/delete policy,
the 1-hour grace window, the 7-day stale cutoff, and the one-shot
activator back-populate migration.
DB schema unchanged — no PRAUTOBLOGGER_DB_VERSION bump.
Final commit of the v0.8.1 bundle.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cost): widen $wpdb mock so ResearchReaperTest actually runs
BaseTestCase::create_mock_wpdb() only adds methods
[prepare, get_var, get_results, insert, query, get_row, update] — the
reaper and amortize_research_costs also call `delete` and `get_col`,
which the default mock can't stub. CI on PR #100 fired all five new
ResearchReaperTest cases as errors at the first `->method('delete')`
call during setUp.
Fix: build a bespoke $wpdb mock in this file's setUp with the full
superset of methods it needs. Self-contained; no change to
BaseTestCase so existing tests aren't affected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cost): filter gen_log inserts from event_log inserts in reaper tests
CI on ec1efee showed the two reap-and-amortize tests failing because
`assertCount(2, $this->inserted_rows)` was seeing 4 rows, not 2. Root
cause: the Logger singleton writes INFO lines via `$wpdb->insert` into
the event_log table — the reaper logs once ("Reaped...") and amortize
logs once ("Amortized LLM research cost...") before the two per-post
gen_log inserts, so the capture ended up with 4 total rows.
Add a `gen_log_inserts()` helper that filters the capture down to rows
whose `__table` contains "generation_log" — the assertions on
attribution count should only measure the actual cost inserts, not
incidental log writes. Two tests updated; logic unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): correct misleading GA4 Property ID description
The GA4 client at includes/core/class-ga4-client.php:85 already wraps the
property_id option with "properties/" in the outbound URL path. The admin
description told users to enter "properties/XXXXXXXXX" — which would have
produced a double-prefixed URL (properties/properties/XXXXXXXXX) and a
guaranteed 404 from the Data API. Description now asks for digits only,
matching actual code behavior.
No code logic change — UX copy + CHANGELOG only. Flagged by CEO while
inspecting the Analytics tab; folded into the in-flight v0.8.1 PR per
CTO call rather than cutting a separate patch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: peptiderepo <peptiderepo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>