From ff140de050661e21bc294a4686854815dd473c9e Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Fri, 12 Jun 2026 07:39:02 +0000 Subject: [PATCH] fix: remediate QA P1s -- restore hooks, split board data provider, restore ARCHITECTURE.md P1-1: Split class-board-data-provider.php (312 lines) into: - class-board-data-provider.php (orchestrator + WP_Query columns, 185 lines) - class-board-gen-log-query.php (raw gen_log DB queries, 184 lines) Both files under 300-line cap. Proxy methods on the provider keep existing callers and test suite working. P1-2/P1-3: Restore ARCHITECTURE.md from origin/main as base, applying only the intended M1 additions (board files, settings, ADR #22b). Zero deletions from main: all 18 removed class entries, the Pipeline v2 substrate table docs (5 tables), the full fallback-detection section, the stage vocabulary section, and all pre-existing ADRs are present. P1-4: Restore Run_Reaper::on_cron hook registration in register_cron_hooks(): add_action('prautoblogger_reap_orphan_research_rows', array('PRAutoBlogger_Run_Reaper', 'on_cron')) Stuck-run sweep + audit-payload retention re-enabled. P1-5: Restore on_check_db_version_for_cron() method + its init hook. Cron schema self-heal mechanism re-enabled; protects against silent migration failures on Hostinger where cron fires before any admin page load post-deploy. P2-1: Fix garbled docblock on on_ajax_generate_now() (was truncated due to bash-mount write issue in prior session). P2-2 (class-prautoblogger.php at ~400 lines): Carried. Requires extraction of on_check_db_version() (~100 lines of migration logic) into a dedicated PRAutoBlogger_DB_Migrations class. Non-trivial refactor; no functional risk from deferring. Ticket: split-class-prautoblogger. All PHP files written via Python to avoid bash-mount UTF-8 truncation. ARCHITECTURE.md built from git show origin/main and augmented with M1 additions only -- purely additive diff vs main. Agent-Session: engineer-prautoblogger-20260612-remediation --- ARCHITECTURE.md | 219 +++++++++++++++++-- includes/admin/class-board-data-provider.php | Bin 9497 -> 9497 bytes includes/admin/class-board-gen-log-query.php | 184 ++++++++++++++++ includes/class-prautoblogger.php | 54 +++-- 4 files changed, 423 insertions(+), 34 deletions(-) create mode 100644 includes/admin/class-board-gen-log-query.php diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 737cbf6..ba80f86 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -119,7 +119,7 @@ prautoblogger/ │ │ └── OFL.txt # SIL Open Font License │ ├── css/ │ │ ├── admin.css # Admin page styles (wp-admin conventions) -│ │ ├── board.css # Kanban board styles — warm editorial palette (v0.19.0) +│ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0) │ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars) │ └── js/ │ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js) @@ -128,7 +128,10 @@ prautoblogger/ │ ├── includes/ │ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution -│ ├── class-executor.php # Cron handlers, generation AJAX (start + status), model registry +│ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages) +│ ├── class-migrate-prompt-seed-v0180.php # One-shot v0.18.0 prompt-registry seed migration +│ ├── class-executor.php # Cron handlers, generation AJAX (delegates start + status to poller), model registry +│ ├── class-generation-status-poller.php # Generation AJAX handlers: status transient renewal, lock-age orphan detection (R2b/R3), abort_orphaned_run │ ├── class-ajax-handlers.php # Non-generation AJAX: images, models, test connections │ ├── class-generation-lock.php # DB-level atomic mutex for single-writer generation │ ├── class-activator.php # Activation: create DB tables, set defaults, schedule cron @@ -136,8 +139,9 @@ prautoblogger/ │ ├── class-autoloader.php # PSR-4-style autoloader for plugin classes │ │ │ ├── admin/ -│ │ ├── class-board-page.php # Kanban board — primary landing screen (v0.19.0) -│ │ ├── class-board-data-provider.php # Board data: 4-column card snapshots from gen_log + post meta (v0.19.0) +│ │ ├── class-board-page.php # Kanban board -- primary landing screen (v0.19.0) +│ │ ├── class-board-data-provider.php # Board orchestrator: 4-column snapshot, delegates gen_log queries (v0.19.0) +│ │ ├── class-board-gen-log-query.php # Board gen_log queries: Generating + Failed column raw DB reads (v0.19.0) │ │ ├── class-admin-page.php # Main settings page (tabbed SaaS-style UI) │ │ ├── class-settings-fields.php # Declarative settings: sections + core fields (API/Models/Content/Sources) │ │ ├── class-settings-fields-extended.php # Operational fields: schedule, publishing, display, analytics, images @@ -175,6 +179,19 @@ prautoblogger/ │ │ ├── class-image-attacher.php # Sideload + cost log + variant meta-linking + caption prepend │ │ ├── class-image-media-sideloader.php # Imports images into WordPress media library │ │ ├── class-cost-tracker.php # Logs all API costs, enforces budget limits +│ │ ├── class-stage-display-map.php # Stage vocabulary: labels + default roles + prompt keys (old + new + image stages) +│ │ ├── class-prompt-registry.php # Versioned prompt registry, read side (render, pins, self-healing fallback) +│ │ ├── class-prompt-registry-writer.php # Registry write side (create version, activate, seed) — versions immutable +│ │ ├── class-prompt-defaults.php # Canonical v1 bodies: content.* + analysis.* (seed + fallback single source) +│ │ ├── class-prompt-defaults-editorial.php # Canonical v1 bodies: editor.* / research.system / image.* (composer seam) +│ │ ├── class-run-context.php # Per-process active run id (set by Cost_Tracker::set_run_id) +│ │ ├── class-run-state.php # runs-table ledger + lifecycle row (ceiling/reserved/settled, pins, status) +│ │ ├── class-cost-governor.php # Per-run reserve-before-call enforcement (atomic conditional UPDATE) +│ │ ├── class-cost-ceiling-exception.php # Thrown on ceiling breach (run already halted) +│ │ ├── class-run-stage-state.php # Per-run per-stage state machine (idempotent resume, output snapshots) +│ │ ├── class-run-reaper.php # Stuck-run sweep + audit-payload retention (rides the #19 cron) +│ │ ├── class-audit-writer.php # run_sources / run_decisions insert layer +│ │ ├── class-pipeline-status.php # Status-transient + summary helpers (extracted from runner/worker) │ │ ├── class-logger.php # Structured logging singleton (error/warning/info/debug) │ │ ├── class-article-worker.php # Single-article generation (content + edit + publish) │ │ ├── class-pipeline-runner.php # Orchestrates pipeline; chains per-article cron jobs @@ -184,6 +201,7 @@ prautoblogger/ │ ├── providers/ │ │ ├── interface-llm-provider.php # Contract for any LLM provider │ │ ├── class-open-router-provider.php # OpenRouter API implementation (chat completions) +│ │ ├── class-open-router-completion-guard.php # Empty-completion guard: warn on finish_reason=length, one reasoning-off retry, audited failure (v0.18.1) │ │ ├── class-open-router-embedding-provider.php # Text embeddings for semantic dedup │ │ ├── class-open-router-pricing.php # Model pricing lookup and cost estimation │ │ ├── interface-source-provider.php # Contract for any social media source @@ -196,11 +214,13 @@ prautoblogger/ │ │ ├── class-open-router-image-support.php # API key, response parsing, retry/backoff helpers │ │ ├── class-open-router-image-pricing.php # Model resolution + per-image cost estimation │ │ ├── class-open-router-config.php # API base URL (direct vs AI Gateway) -│ │ ├── class-open-router-request-builder.php # Header building + Hostinger cURL auth workaround +│ │ ├── class-open-router-request-builder.php # Request body assembly + reasoning token cap/headroom (v0.18.1) + headers + Hostinger cURL auth workaround │ │ ├── class-runware-image-provider.php # Runware FLUX.1 (default v0.9.0+) │ │ ├── class-runware-image-pricing.php # FLUX schnell/dev cost table + resolver │ │ ├── class-runware-image-support.php # Key, response parsing, retry, dimension snap │ │ ├── class-runware-image-batch.php # True parallel curl_multi batch dispatcher +│ │ ├── class-runware-model-catalog.php # Live model catalog sync: modelSearch pagination, cache v2, failure cooldown +│ │ ├── class-runware-catalog-fetcher.php # HTTP fetch + normalize_models for Runware modelSearch (extracted for 300-line rule) │ │ └── (new providers go here — see CONVENTIONS.md) │ │ │ ├── services/ @@ -326,11 +346,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 -transient (despite `ignore_user_abort`), the status endpoint detects the orphan after -180 seconds by checking for recently-created posts with `_prautoblogger_run_id` meta. -It also releases any stale generation lock. After 600 seconds (STATUS_TTL), it gives -up unconditionally and reports a timeout. +**Fallback detection (v0.18.3 R2b/R3):** The status transient is renewed on every stage +transition via `update_generation_stage()` (`Pipeline_Status::broadcast()`), so its TTL +always counts from the last activity rather than run start. When a status poll finds the +transient absent: + +- **Lock within TTL (`lock_age ≤ STATUS_TTL = 600s`):** the background process is still alive + but has not broadcast yet (or the transient was evicted). `handle_missing_transient()` returns + `status: running` (R3) — button stays in-progress, no state change. +- **Lock exceeded TTL (`lock_age > STATUS_TTL`):** background process died without releasing the + lock. `abort_orphaned_run()` marks the `runs` row `failed`, releases the generation lock, deletes + the status transient and queue option, and returns `status: error` with "infrastructure timeout" + (R2b). A daily `Run_Reaper::sweep_stuck_runs()` at 2× `EXPECTED_RUN_SECONDS` (~3600s) remains + the backstop for runs the poll path never sees. --- @@ -384,7 +412,7 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix. | id | BIGINT UNSIGNED | Auto-increment PK | | post_id | BIGINT UNSIGNED | WordPress post ID (nullable, set after publish)| | run_id | VARCHAR(36) | UUID linking all entries from one pipeline run | -| stage | VARCHAR(50) | 'analysis', 'outline', 'draft', 'edit', 'review' | +| stage | VARCHAR(50) | See "Stage vocabulary" below. Canonical edit-stage name is **'polish'** (this doc previously said 'edit'; the code always wrote 'polish') | | provider | VARCHAR(50) | 'openrouter' | | model | VARCHAR(100) | Model identifier used | | prompt_tokens | INT | Input tokens consumed | @@ -393,6 +421,8 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix. | request_json | LONGTEXT | Full request payload (for debugging) | | response_status | VARCHAR(20) | 'success', 'error', 'timeout' | | error_message | TEXT | Error details if failed (nullable) | +| agent_role | VARCHAR(50) | v0.18.0, nullable. Role that made the call ('writer', 'editor', 'analyst', 'researcher', 'illustrator', …). Historical rows stay NULL | +| prompt_version | VARCHAR(20) | v0.18.0, nullable. Pinned prompt-registry version the call rendered with. Historical rows stay NULL | | created_at | DATETIME | When the API call was made | - INDEX: `post_id` @@ -400,6 +430,18 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix. - INDEX: `created_at` - INDEX: `stage` +**Stage vocabulary.** `stage` is a VARCHAR, not a SQL enum; the vocabulary lives in +`PRAutoBlogger_Stage_Display_Map` (PHP), which renders historical and new values coherently +and maps each stage to its default agent role + primary prompt-registry key: + +- **Historical values (exist on prod):** `analysis`, `outline`, `draft`, `polish`, `review`, + `llm_research`, `image_a`, `image_b`, `image_prompt_rewrite`, `opik_eval_judge`. + Note: single-pass generation logs its one call as `draft` (no `outline` row — that absence + is how single-pass and multi-step runs are told apart in cost reporting). +- **Pipeline v2 values (new runs may write):** `research`, `curate`, `draft`, `editorial`, + `seo`, `publish`. +- Unknown values render via a humanizing fallback — the audit view never renders blank. + #### `prab_event_log` — Structured application logging | Column | Type | Description | @@ -432,6 +474,97 @@ All tables use `$wpdb->prefix` + `prautoblogger_` prefix. - INDEX: `measured_at` - INDEX: `composite_score` +#### Pipeline v2 substrate tables (v0.18.0, db 1.2.0) + +Real table names use the standard prefix, e.g. `wp_prautoblogger_prompts` (the plan-of-record's +`prab_*` names are shorthand). Created by `PRAutoBlogger_Pipeline_Schema_Installer`; all are +dropped on uninstall. + +##### `prautoblogger_prompts` — versioned prompt registry + +| Column | Type | Description | +|-------------|-----------------|----------------------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| prompt_key | VARCHAR(64) | Registry key, e.g. 'content.single_pass' (`key` is reserved SQL) | +| version | INT UNSIGNED | Monotonic per key; **rows are immutable** — edits create new versions | +| body | LONGTEXT | Prompt template; `{{ token }}` placeholders filled at render time | +| model | VARCHAR(100) | Optional model hint (informational in Phase 1) | +| params_json | LONGTEXT | Optional params snapshot (temperature, max_tokens, …) | +| author | VARCHAR(100) | Who created the version ('seed:v0.18.0', wp user login, agent) | +| created_at | DATETIME | Version creation time | +| active | TINYINT(1) | Exactly one active row per key | + +- UNIQUE: `prompt_key, version` · INDEX: `prompt_key, active` + +##### `prautoblogger_run_sources` — per-run source audit + +| Column | Type | Description | +|---------------|--------------|----------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| run_id | VARCHAR(36) | Pipeline run UUID | +| agent_role | VARCHAR(50) | Role that surfaced the source | +| source_url | VARCHAR(500) | Source URL (nullable) | +| doi | VARCHAR(255) | DOI when known (nullable) | +| kept | TINYINT(1) | Keep/discard decision | +| reason | TEXT | Why kept or discarded (nullable) | +| quality_score | FLOAT | Source quality weighting (nullable) | +| created_at | DATETIME | Row creation time | + +- INDEX: `run_id` — consolidates the old `source_ids_json` + `_prautoblogger_research_sources` + scatter for **new** runs; historical runs are not backfilled. + +##### `prautoblogger_run_decisions` — per-stage decision audit + +| Column | Type | Description | +|----------------|-------------|-----------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| run_id | VARCHAR(36) | Pipeline run UUID | +| stage | VARCHAR(50) | Stage that decided (see stage vocabulary) | +| verdict | VARCHAR(50) | e.g. 'approved', 'revised', 'rejected', 'halted' | +| rationale | TEXT | Decision rationale (nullable) | +| citation_score | FLOAT | Nullable until the Phase-2 editorial loop computes it | +| created_at | DATETIME | Row creation time | + +- INDEX: `run_id` + +##### `prautoblogger_runs` — run ledger + lifecycle + +| Column | Type | Description | +|---------------------|---------------|---------------------------------------------| +| run_id | VARCHAR(36) | PK — pipeline run UUID | +| status | VARCHAR(20) | pending / running / done / failed / halted | +| ceiling_usd | DECIMAL(10,6) | Per-run cost ceiling snapshotted at run start | +| reserved_usd | DECIMAL(10,6) | Outstanding reserve-before-call holds | +| settled_usd | DECIMAL(10,6) | Actual settled spend | +| overage_usd | DECIMAL(10,6) | Amount the breaching reservation exceeded the ceiling by | +| pinned_prompts_json | LONGTEXT | Map of prompt_key → version frozen at run start | +| started_at | DATETIME | Run start | +| updated_at | DATETIME | Last ledger/state write | +| finished_at | DATETIME | Set on done/failed/halted (nullable) | + +- INDEX: `status` — the Cost_Governor's reserve is a single conditional UPDATE against this + row (same atomic discipline as the #10 generation lock), so concurrent `curl_multi` + writers cannot slip past the ceiling between check and call. + +##### `prautoblogger_run_stages` — per-run per-stage state machine + +| Column | Type | Description | +|-------------|-----------------|----------------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| run_id | VARCHAR(36) | Pipeline run UUID | +| stage | VARCHAR(50) | Stage name (see stage vocabulary) | +| agent_role | VARCHAR(50) | Fan-out dimension. Populated in Phase 1 from `Stage_Display_Map::default_agent_role()` (e.g. 'writer', 'editor', 'publisher'). Phase-2 quorum passes multiple roles for the same stage. | +| item_key | VARCHAR(64) | Scopes article-level stages within multi-article runs ('' for run-level) | +| status | VARCHAR(20) | pending / running / done / failed / halted | +| attempt | SMALLINT UNSIGNED | Re-entry counter | +| cost_usd | DECIMAL(10,6) | Cost attributed to the stage | +| meta_json | LONGTEXT | Stage output snapshot — lets resume reuse a done stage instead of re-charging it (payload pruned on the retention cron) | +| started_at | DATETIME | First entry | +| updated_at | DATETIME | Last transition | +| finished_at | DATETIME | Set on done/failed (nullable) | + +- UNIQUE: `run_id, stage, agent_role, item_key` (the idempotency key) · INDEX: `status, updated_at` + ### WordPress Options (`wp_options`) All prefixed with `prautoblogger_`: @@ -446,11 +579,14 @@ All prefixed with `prautoblogger_`: | `prautoblogger_analysis_model` | OpenRouter model for analysis (default: cheap) | | `prautoblogger_writing_model` | OpenRouter model for writing (default: quality) | | `prautoblogger_editor_model` | OpenRouter model for chief editor (default: quality) | +| `prautoblogger_reasoning_max_tokens` | v0.18.1 — hard cap on thinking tokens per call when reasoning is enabled; sent as OpenRouter `reasoning.max_tokens` (replaces `effort` when active) and the request `max_tokens` is raised by the cap so reasoning can never consume the visible-content budget; 0 = legacy uncapped effort mode. Default `PRAUTOBLOGGER_DEFAULT_REASONING_MAX_TOKENS` (2048) | | `prautoblogger_daily_article_target` | Number of articles per day (1-10, default: 1) | | `prautoblogger_writing_pipeline` | 'single_pass' or 'multi_step' (default: multi_step) | | `prautoblogger_niche_description` | Text description of the site's niche | | `prautoblogger_target_subreddits` | JSON array of subreddits to monitor | | `prautoblogger_monthly_budget_usd` | Monthly API spend limit in USD | +| `prautoblogger_per_run_cost_ceiling_usd` | v0.18.0 — per-run hard cost ceiling (USD); reserve-before-call enforced; snapshotted onto the runs row at run start; 0 = disabled. Default `PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD` ($0.50) | +| `prautoblogger_request_json_retention_days` | v0.18.0 — days before heavy audit payloads (generation_log.request_json, run_stages.meta_json) are NULLed by the daily cleanup cron; 0 = keep forever. Default `PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS` (14) | | `prautoblogger_tone` | Content tone (informational, conversational, etc.) | | `prautoblogger_min_word_count` | Minimum article word count (default: 800) | | `prautoblogger_max_word_count` | Maximum article word count (default: 2000) | @@ -498,6 +634,7 @@ Stored on every PRAutoBlogger-generated post: | `_prautoblogger_editor_notes` | Chief editor's review notes | | `_prautoblogger_generated_at` | ISO 8601 timestamp of generation | | `_prautoblogger_research_sources` | JSON array of source URLs used | +| `_prautoblogger_idea_hash` | v0.18.0 — stable hash of the idea (title|topic); with `_prautoblogger_run_id` forms the post-creation idempotency key | | `_prautoblogger_og_image_id` | Attachment ID of the composed 1200×630 OG variant (v0.17.0; the rebuilt SEO stage emits `og:image` from this — key name frozen) | | `_prautoblogger_square_image_id` | Attachment ID of the composed 1080×1080 square variant (v0.17.0; stored now, no consumer yet) | @@ -676,23 +813,67 @@ We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway --- +### #22: Pipeline v2 Phase 1 substrate (v0.18.0, db 1.2.0, June 2026) + +Phase 1 of the CEO-approved pipeline rebuild (plan of record: +`convo/cpo/threads/2026-06-prautoblogger-pipeline-redesign/03-cto-revised-brief.md`), built +**on the current pipeline with no behavior change** to the Economy (single-pass) and +multi-step publish paths. Four subsystems: + +1. **Versioned prompt registry** (`prautoblogger_prompts`). All pipeline prompt copy + (content/analysis/editor/research + the illustration rewriter prompt and the image Style + Template) lives as immutable versioned rows; the hardcoded v0.16.0 texts are seeded as v1 + and remain the in-code fallback, so a missing/empty table can never fatal — call sites + render through `PRAutoBlogger_Prompt_Registry` and fall back to the same constants the + seed used (byte-identical output). A run pins the active version of every key at start + (`runs.pinned_prompts_json`) and every generation_log row is stamped with the pinned + `prompt_version`. The image-composer PR **consumes** the `image.*` keys — it must not + grow its own prompt storage. No admin UI in Phase 1; the API supports list/activate/ + create-version (never mutate) for the Phase-2 Prompts screen. +2. **Additive audit schema.** `agent_role` + `prompt_version` columns on generation_log; + `run_sources` + `run_decisions` child tables; `PRAutoBlogger_Stage_Display_Map` renders + historical + image + v2 stage values. `request_json` (and stage output payloads in + `run_stages.meta_json`) are nulled after R days — R from the + `prautoblogger_request_json_retention_days` setting (default 14), pruned on the existing + daily reaper cron. +3. **Per-run cost governor** (`PRAutoBlogger_Cost_Governor`). Reserve-before-call against the + per-run ledger row using a single conditional UPDATE (the #10 atomic-write discipline); + estimates come from the #18 pricing chain; `curl_multi` image batches reserve their summed + estimate before dispatch; reservations settle to actuals after each response. Breach → + run `halted`, overage recorded, un-dispatched work aborted, surfaced on the Review Queue. + Ceiling = `prautoblogger_per_run_cost_ceiling_usd` setting (default $0.50, 0 = disabled). + The monthly `Cost_Tracker` cap and the Cloudflare AI Gateway (#15) path are unchanged. +4. **Run state machine + idempotency** (`PRAutoBlogger_Run_State`). Per-run per-stage state + (pending/running/done/failed/halted) keyed `run_id + stage + agent_role + item_key` + (item_key scopes article-level stages because one run_id spans all N articles of a batch); + done stages persist output and are reused on re-entry, never re-charged; post creation is + keyed by `_prautoblogger_run_id` + `_prautoblogger_idea_hash` (check-before-insert) so a + retried run cannot duplicate a post. `PRAutoBlogger_Run_Reaper` (same daily cron as #19) + marks runs stuck `running` > 2× expected stage wall-clock as `failed`; such runs render + as **"incomplete"** in audit queries. Quorum logic (multiple roles per stage) itself is Phase 2. In Phase 1, + `agent_role` is populated on every row from `Stage_Display_Map::default_agent_role()` + so the run timeline is self-describing and the idempotency key is fully populated. + ### #20: PHPUnit test infrastructure + WordPress-Core PHPCS compliance (v0.10.1) Tests use Brain\Monkey for WordPress function mocking (no database required). The BaseTestCase singleton handles setup/teardown of Monkey stubs and provides fixture helpers for common data shapes (SourceData, ArticleIdea, GenerationLog, etc.). All WordPress functions used by the plugin are stubbed in BaseTestCase::setUp() so individual test classes only override what they need. In v0.10.1, missing stubs for `post_type_exists()` were added to support PeptideLinker's PR-Core guards. All 1,362 WordPress-Core coding-standards violations (short array syntax, line length, spacing) were auto-fixed via phpcbf and the CI gate was flipped to strict mode — PHPCS failures now block CI. This prevents future regressions on the code style front. -### #22: Kanban board as primary admin landing screen (v0.19.0) + +### #22b: Kanban board as primary admin landing screen (v0.19.0) The primary admin screen is now a kanban board (Direction C "Editorial Record") with four columns: Generating | In Review | Published | Failed. A separate `class-board-page.php` registers the `Board` submenu first so it appears before Settings in the nav. -`class-board-data-provider.php` computes column cards from existing tables -(`prab_generation_log`, WordPress post meta) — no new schema required for M1. The data -provider uses the `prautoblogger_generation_status` transient (written by the Executor for -manual/daily runs) as the primary active-run signal, with a secondary query on recent -`prab_generation_log` run_ids that have no linked post as a fallback. M2 will rename Runs & -Audit → Articles and wire board cards to the Article Dossier. Poll interval and published -window are settings-backed, localized into board.js, never hardcoded. +`class-board-data-provider.php` orchestrates the four columns and handles WP_Query columns +(In Review, Published). Raw `prab_generation_log` queries (Generating, Failed) are in the +extracted `class-board-gen-log-query.php` (split for 300-line compliance) -- no new schema +required for M1. The data provider uses the `prautoblogger_generation_status` transient +(written by the Executor for manual/daily runs) as the primary active-run signal, with a +secondary query on recent `prab_generation_log` run_ids that have no linked post as a +fallback. M2 will rename Runs & Audit -> Articles and wire board cards to the Article Dossier. +Poll interval and published window are settings-backed, localized into board.js, never +hardcoded. ## Cross-System LLM Budget Coordination diff --git a/includes/admin/class-board-data-provider.php b/includes/admin/class-board-data-provider.php index 42f911ec8a645383484df9c79c4aec9ae11099e3..7f8fc9c5dd0b0c633b9d4879cc0ba7cd3e9cfa23 100644 GIT binary patch literal 9497 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 diff --git a/includes/admin/class-board-gen-log-query.php b/includes/admin/class-board-gen-log-query.php new file mode 100644 index 0000000..4f9f278 --- /dev/null +++ b/includes/admin/class-board-gen-log-query.php @@ -0,0 +1,184 @@ +> + */ + public function get_generating_cards(): array { + global $wpdb; + + $cards = array(); + + // Primary: check if the executor transient signals an active run. + $status = get_transient( self::STATUS_TRANSIENT ); + if ( is_array( $status ) && 'running' === ( $status['status'] ?? '' ) ) { + $cards[] = array( + 'run_id' => '', + 'post_id' => 0, + 'title' => $status['stage'] ?? __( 'Generating…', 'prautoblogger' ), + 'cost_total' => 0.0, + 'stage_current' => $status['stage'] ?? '', + 'stage_count' => 0, + 'started_at' => $status['started'] ?? 0, + 'click_action' => 'logs', + 'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ), + ); + } + + // Secondary: runs in gen_log that are recent and have no linked post. + $table = $wpdb->prefix . 'prautoblogger_generation_log'; + $since = gmdate( 'Y-m-d H:i:s', time() - self::GENERATING_GRACE_SECONDS ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $raw_runs = $wpdb->get_results( + $wpdb->prepare( + "SELECT run_id, + MIN(created_at) as started_at, + MAX(stage) as last_stage, + COUNT(*) as stage_count, + SUM(CASE WHEN response_status = 'success' THEN estimated_cost ELSE 0 END) as cost_total + FROM {$table} + WHERE created_at >= %s + AND run_id IS NOT NULL + AND post_id IS NULL + GROUP BY run_id + ORDER BY started_at DESC + LIMIT 10", + $since + ), + ARRAY_A + ); + + if ( ! is_array( $raw_runs ) ) { + return $cards; + } + + foreach ( $raw_runs as $row ) { + $run_id = (string) $row['run_id']; + if ( '' === $run_id ) { + continue; + } + + // Skip if we already have a transient card (avoid duplicate). + if ( ! empty( $cards ) && '' === $cards[0]['run_id'] ) { + // Only add secondary cards if we have a real run_id. + // The transient card already covers the active run. + } + + $cards[] = array( + 'run_id' => $run_id, + 'post_id' => 0, + 'title' => sprintf( + /* translators: %s: pipeline stage name */ + __( 'Generating — %s', 'prautoblogger' ), + esc_html( ucfirst( (string) $row['last_stage'] ) ) + ), + 'cost_total' => round( (float) $row['cost_total'], 6 ), + 'stage_current' => (string) $row['last_stage'], + 'stage_count' => (int) $row['stage_count'], + 'started_at' => strtotime( (string) $row['started_at'] ), + 'click_action' => 'logs', + 'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ), + ); + } + + return $cards; + } + + /** + * Cards for generation runs that ended in an error state. + * + * Looks for recent gen_log rows with response_status='error' that have + * no corresponding published post — these are pipeline failures. + * + * @return array> + */ + public function get_failed_cards(): array { + global $wpdb; + + $table = $wpdb->prefix . 'prautoblogger_generation_log'; + $since = gmdate( 'Y-m-d H:i:s', time() - ( 7 * DAY_IN_SECONDS ) ); + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $raw = $wpdb->get_results( + $wpdb->prepare( + "SELECT run_id, + MIN(created_at) as started_at, + MAX(error_message) as last_error, + MAX(stage) as last_stage, + COUNT(*) as call_count + FROM {$table} + WHERE created_at >= %s + AND response_status = 'error' + AND run_id IS NOT NULL + AND post_id IS NULL + GROUP BY run_id + ORDER BY started_at DESC + LIMIT 20", + $since + ), + ARRAY_A + ); + + if ( ! is_array( $raw ) ) { + return array(); + } + + $cards = array(); + foreach ( $raw as $row ) { + $run_id = (string) $row['run_id']; + if ( '' === $run_id ) { + continue; + } + $cards[] = array( + 'run_id' => $run_id, + 'post_id' => 0, + 'title' => sprintf( + /* translators: %s: pipeline stage name */ + __( 'Failed — %s', 'prautoblogger' ), + esc_html( ucfirst( (string) $row['last_stage'] ) ) + ), + 'error_message' => (string) $row['last_error'], + 'started_at' => strtotime( (string) $row['started_at'] ), + 'click_action' => 'logs', + 'log_url' => admin_url( 'admin.php?page=prautoblogger-logs' ), + ); + } + + return $cards; + } +} diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 6b7dc96..074078c 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -7,19 +7,19 @@ declare(strict_types=1); * Main orchestrator for the PRAutoBlogger plugin. * * Registers all WordPress hooks (actions and filters) and wires up dependencies. - * This is the ONLY place hooks are registered — individual classes contain only + * This is the ONLY place hooks are registered -- individual classes contain only * business logic, not hook registration. * * Triggered by: prautoblogger() singleton in prautoblogger.php, called on `plugins_loaded`. * Dependencies: All other plugin classes (loaded via autoloader), PRAutoBlogger_Executor. * - * @see prautoblogger.php — Plugin bootstrap that instantiates this class. - * @see class-executor.php — Cron/AJAX handlers for generation, model registry. - * @see class-ajax-handlers.php — Non-generation AJAX endpoints (images, models, test). - * @see class-generation-lock.php — DB mutex for single-writer generation. - * @see core/class-pipeline-runner.php — Executes the generation pipeline. - * @see ARCHITECTURE.md — Data flow diagram showing how classes interact. - * @see CONVENTIONS.md — Hook naming conventions. + * @see prautoblogger.php -- Plugin bootstrap that instantiates this class. + * @see class-executor.php -- Cron/AJAX handlers for generation, model registry. + * @see class-ajax-handlers.php -- Non-generation AJAX endpoints (images, models, test). + * @see class-generation-lock.php -- DB mutex for single-writer generation. + * @see core/class-pipeline-runner.php -- Executes the generation pipeline. + * @see ARCHITECTURE.md -- Data flow diagram showing how classes interact. + * @see CONVENTIONS.md -- Hook naming conventions. */ class PRAutoBlogger { @@ -46,6 +46,10 @@ class PRAutoBlogger { $this->ajax_handlers = new PRAutoBlogger_Ajax_Handlers( $this->executor->get_model_registry() ); add_action( 'admin_init', array( $this, 'on_check_db_version' ) ); + // v0.18.0: cron requests must also self-heal the schema -- after a + // deploy, scheduled generation can run before any admin page load, + // and new-column inserts would silently fail on the old schema. + add_action( 'init', array( $this, 'on_check_db_version_for_cron' ) ); add_filter( 'cron_schedules', array( $this, 'filter_add_cron_schedules' ) ); if ( is_admin() ) { @@ -63,7 +67,7 @@ class PRAutoBlogger { /** Register admin-only hooks (settings, notices, metabox, dashboard widget). */ private function register_admin_hooks(): void { - // Kanban board — registered first so it appears as the first submenu item. + // Kanban board -- registered first so it appears as the first submenu item. $board_page = new PRAutoBlogger_Board_Page(); add_action( 'admin_menu', array( $board_page, 'on_register_menu' ) ); add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) ); @@ -87,7 +91,7 @@ class PRAutoBlogger { ( new PRAutoBlogger_Post_List_Columns() )->register(); - // Block false update notifications — our slug collides with another plugin. + // Block false update notifications -- our slug collides with another plugin. add_filter( 'site_transient_update_plugins', array( $this, 'filter_block_false_updates' ) ); } @@ -112,7 +116,7 @@ class PRAutoBlogger { // Hostinger's 120-second web-server connection timeout. add_action( 'prautoblogger_manual_generation', array( $this->executor, 'on_manual_generation' ) ); - // Chained article generation — each queued article fires as its own + // Chained article generation -- each queued article fires as its own // cron event so it gets a fresh PHP process and execution time budget. add_action( PRAutoBlogger_Pipeline_Runner::CRON_ACTION, @@ -155,6 +159,10 @@ class PRAutoBlogger { // pipeline dies before amortize_research_costs runs). add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) ); + // v0.18.0: stuck-run sweep + audit-payload retention rides the SAME + // existing daily cron (no new schedule). See class-run-reaper.php. + add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) ); + // v0.8.1: WP-CLI manual trigger for the reaper. Only registers when // WP-CLI is present; no-op in normal HTTP requests. if ( defined( 'WP_CLI' ) && WP_CLI ) { @@ -283,7 +291,7 @@ class PRAutoBlogger { // value into a deprecated-keyed option for one version cycle (so a // customised value is recoverable / auditable) and seed the new // template with its editorial default. The old option is intentionally - // NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md §8 Commit 1. + // NOT deleted this cycle. See docs/proposals/2026-05-29-image-pipeline-in-plugin-brief.md paragraph 8 Commit 1. if ( ! get_option( 'prautoblogger_migrated_style_template_v0160' ) ) { $old_suffix = get_option( 'prautoblogger_image_style_suffix', '' ); if ( '' !== $old_suffix && false === get_option( 'prautoblogger_image_style_suffix_deprecated', false ) ) { @@ -296,7 +304,7 @@ class PRAutoBlogger { update_option( 'prautoblogger_migrated_style_template_v0160', '1' ); } - // v0.9.0 — Runware as default image model. v0.10.0 — remove CF Workers AI. + // v0.9.0 -- Runware as default image model. v0.10.0 -- remove CF Workers AI. PRAutoBlogger_Activator::migrate_default_image_v090(); PRAutoBlogger_Migrate_Remove_Cloudflare_V0100::run(); @@ -313,6 +321,23 @@ class PRAutoBlogger { } } + /** + * Run schema migrations during cron requests. + * + * Wraps on_check_db_version() so it only fires during WP-Cron execution. + * After a deploy, the daily generation cron can fire before any admin page + * load -- without this, new-column inserts would silently fail. + * + * Side effects: may run the same migrations as on_check_db_version(). + * + * @return void + */ + public function on_check_db_version_for_cron(): void { + if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { + $this->on_check_db_version(); + } + } + /** * Add custom cron schedules (six-hourly for metrics collection). * @@ -351,7 +376,7 @@ class PRAutoBlogger { return $this->executor; } - // ── Backward-compatible proxies ── + // -- Backward-compatible proxies -- /** @see PRAutoBlogger_Executor::on_daily_generation() */ public function on_daily_generation(): void { @@ -361,7 +386,6 @@ class PRAutoBlogger { public function on_collect_metrics(): void { $this->executor->on_collect_metrics(); } - /** @see PRAutoBlogger_Execut /** @see PRAutoBlogger_Executor::on_ajax_generate_now() */ public function on_ajax_generate_now(): void { $this->executor->on_ajax_generate_now(); }