diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 323a92f..581ad5f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -121,17 +121,19 @@ prautoblogger/ │ │ ├── admin.css # Admin page styles (wp-admin conventions) │ │ ├── board.css # Kanban board styles -- warm editorial palette (v0.19.0) │ │ ├── dossier.css # Article dossier page styles: verdict pills, cost receipt, trace toggle (v0.19.2) +│ │ ├── dossier-edit.css # M3 edit/re-run layer: chips, edit panel, image grid, spend warning (v0.20.0) │ │ └── posts-widget.css # Frontend posts widget styles (uses theme CSS vars) │ └── js/ │ ├── admin.js # Admin page interactivity (vanilla JS / Alpine.js) │ ├── board.js # Board poller: AJAX poll + DOM updates + backoff (v0.19.0) │ ├── dossier.js # Per-stage raw-trace toggle: aria-expanded / aria-controls (v0.19.2) +│ ├── dossier-edit.js # M3 edit/re-run actions + stage-status polling (queued -> pickup -> result) (v0.20.0) │ └── posts-widget.js # React component for frontend post cards (wp.element) │ ├── includes/ -│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (258 lines; split v0.19.2) +│ ├── class-prautoblogger.php # Main orchestrator — registers all hooks, delegates execution (split v0.19.2) │ ├── class-db-migrations.php # DB migration methods extracted from class-prautoblogger.php (v0.19.2 split, binding 1) -│ ├── class-pipeline-schema-installer.php # v0.18.0 substrate tables (prompts, run_sources, run_decisions, runs, run_stages) +│ ├── class-pipeline-schema-installer.php # Pipeline substrate tables (prompts, run_sources, run_decisions, runs, run_stages, stage_inputs) │ ├── 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 @@ -148,6 +150,9 @@ prautoblogger/ │ │ ├── class-dossier-page.php # Article dossier admin page: options.php-parent hidden-page, priority 12, enqueue, nonce (v0.19.3) │ │ ├── class-dossier-data-assembler.php # 5-query view-model builder: runs+stages+gen_log+decisions+meta (v0.19.2) │ │ ├── class-dossier-gen-log-query.php # Dossier gen_log queries: per-stage cost receipt + raw trace (v0.19.2) +│ │ ├── class-dossier-actions.php # M3 AJAX: save fork / queue replay / queue rebuild / stage-status poll (v0.20.0) +│ │ ├── class-dossier-rerun-panel-data.php # M3 per-stage affordance + spend view model (v0.20.0) +│ │ ├── class-dossier-image-data.php # F3: post's pipeline attachments via _prautoblogger_image_role (v0.20.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 @@ -194,7 +199,15 @@ prautoblogger/ │ │ ├── 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-stage-state.php # Per-run per-stage state machine: read API + thin write proxies (split v0.20.0) +│ │ ├── class-run-stage-writes.php # Pipeline-path writes (start/done/fail; done clears stale) — extracted v0.20.0 +│ │ ├── class-run-stage-rerun-state.php # M3 operator-action mutations: restart/mark_stale/demote (v0.20.0) +│ │ ├── class-stage-input-store.php # INSERT-only stage_inputs store: edit forks + idea seeds (v0.20.0) +│ │ ├── class-rerun-eligibility.php # M3 policy gates: frozen posts, editable stages, chain order (v0.20.0) +│ │ ├── class-stage-replay.php # M3 governed single-stage replay from a fork body (v0.20.0) +│ │ ├── class-rerun-executor.php # M3 cron handlers: replay + rebuild jobs (v0.20.0) +│ │ ├── class-rerun-job-support.php # M3 queue/lock/budget/status-transient plumbing (v0.20.0) +│ │ ├── class-request-recorder.php # B1: process-scoped outbound request-body stash -> generation_log.request_json (v0.20.0) │ │ ├── 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) @@ -252,6 +265,10 @@ prautoblogger/ │ └── admin/ │ ├── board-page.php # Kanban board template (4-column editorial layout) (v0.19.0) │ ├── dossier-page.php # Article dossier: two-column layout, sidebar, stage sections (v0.19.2) +│ ├── dossier-stage-section.php # Stage section partial: output, raw trace, M3 chips + rerun footer +│ ├── dossier-edit-panel.php # M3 per-stage edit panel: message textareas, fork info, spend strip (v0.20.0) +│ ├── dossier-log-stage-section.php # F3 log-only stage sections incl. image attachment grid (v0.20.0) +│ ├── dossier-sidebar-cards.php # M3 sidebar: run spend (guardrail 4) + models/pv consolidation (F2) (v0.20.0) │ ├── dossier-stage-section.php # Per-stage block: rendered output + raw-trace toggle + cost receipt (v0.19.2) │ ├── metabox-dossier-link.php # Slim post metabox: "View generation dossier →" link (v0.19.2) │ ├── settings-page.php # Settings page template (tabbed sidebar layout) @@ -532,6 +549,7 @@ dropped on uninstall. | 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 | +| human_modified | TINYINT(1) | v0.20.0 — decision derives from a human-edited input (set on replay of the stage, or on decisions recorded while rebuilding a human-modified item) | | created_at | DATETIME | Row creation time | - INDEX: `run_id` @@ -568,12 +586,38 @@ dropped on uninstall. | 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) | +| human_modified | TINYINT(1) | v0.20.0 — set (sticky, never cleared) when an edited input fork enters execution for this row (CPO guardrail 2) | +| stale | TINYINT(1) | v0.20.0 — upstream changed after this row completed; row STAYS 'done' so resume never silently re-runs it; cleared only by a fresh done() | | 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` +##### `prautoblogger_stage_inputs` — immutable input-version store (v0.20.0, db 1.3.0) + +| Column | Type | Description | +|--------------|-----------------|--------------------------------------------------| +| id | BIGINT UNSIGNED | Auto-increment PK | +| run_id | VARCHAR(36) | Pipeline run UUID | +| stage | VARCHAR(50) | Stage name ('' for idea seeds) | +| agent_role | VARCHAR(50) | Stage row agent role | +| item_key | VARCHAR(64) | Article scope | +| version | INT UNSIGNED | Monotonic per scope; **rows are INSERT-only** — edits create the next version, originals are never overwritten | +| source | VARCHAR(20) | 'human' (edit fork) or 'seed' (Article_Idea payload persisted at worker start) | +| request_json | LONGTEXT | Fork body / idea JSON. Human fork bodies pruned by the retention cron after R days; seed rows (~1KB) kept | +| author | VARCHAR(100) | WP user login, or 'pipeline' for seeds | +| created_at | DATETIME | Row creation time | + +- UNIQUE: `run_id, stage, agent_role, item_key, version` · INDEX: `run_id` +- The ORIGINAL executed input of a stage is `generation_log.request_json` (B1) — it is + never copied or modified here; forks are the edit history (CPO guardrail 1). + +**db version note:** `PRAUTOBLOGGER_DB_VERSION` moved 1.1.0 → 1.3.0 in v0.20.0. The +constant never actually carried the "db 1.2.0" this document used as shorthand for the +v0.18.0 substrate; 1.3.0 leapfrogs the phantom value so the version-compare self-healing +migration fires exactly once everywhere. + ### WordPress Options (`wp_options`) All prefixed with `prautoblogger_`: @@ -964,6 +1008,70 @@ the file `class-db-migrations.php`. Explicit loading is the canonical pattern fo whose names do not round-trip cleanly through the kebab converter. `class-prautoblogger.php` uses the same pattern (also explicitly required in `prautoblogger.php`). +### #24: Edit + single-step re-run under CPO guardrails (v0.20.0, db 1.3.0, June 2026) + +Phase 2 admin M3 (design contract: cpo seq 8 Proposal C + the five guardrails of the +edit+rerun delta ruling; decision `2026-06-12-admin-redesign-direction-c.md`). + +**B1 — request_json persistence.** The OpenRouter provider stashes every outgoing chat +request BODY in the process-scoped `PRAutoBlogger_Request_Recorder` (the Run_Context +pattern) right after `build_body()`; `Cost_Tracker::log_api_call()` consumes it into +`generation_log.request_json`. Consume-once + overwrite-on-record means a stale body can +never attach to an unrelated row; recording happens pre-dispatch so error rows carry the +request too. Authorization can never be recorded: headers are built separately +(`build_headers()`) and `build_body()` copies only whitelisted option keys. Retention +rides the existing `prautoblogger_request_json_retention_days` prune unchanged. + +**Two re-run primitives, both chained-cron (never synchronous):** the dossier AJAX layer +(`Dossier_Actions`) only validates + queues; the cron handlers (`Rerun_Executor`) +re-validate eligibility UNDER the generation lock before mutating anything. + +1. **Replay** — one governed chat call from the latest edited input fork + (`stage_inputs`, INSERT-only versions). `Stage_Replay::options_from_body()` is the + exact inverse of `build_body()`+`apply_reasoning_budget()` (reasoning headroom + subtracted back out; reasoning pinned explicitly so global-setting drift cannot + reshape a replay). The call goes through `send_chat_completion`, so the cost + governor reserves against the SAME run ledger row (guardrail 4), with the normal + retry/empty-completion machinery. Output lands in the stage's run_stages snapshot — + the same place downstream rebuilds read from. +2. **Rebuild ("re-run from here")** — demotes the target + downstream chain stages to + `pending` and re-enters `Article_Worker` with the idea reconstructed from the stored + seed (`stage_inputs`, source='seed', persisted at worker start because re-deriving + the idea from post fields could break the item_key hash and duplicate a post). + Upstream done stages are reused (never re-charged); demoted stages rebuild their + prompts from CURRENT upstream snapshots — this is how an edit propagates downstream. + +**Stale is a column, not a status.** A stale stage stays `done`, so +resume-without-recharge logic can never silently re-run it — guardrail 3 (no silent +auto-rerun; each downstream re-run is a deliberate operator action) holds by +construction. Only `Run_Stage_Writes::done()` clears `stale`; only +`Run_Stage_Rerun_State` (operator-action paths) may demote a done row. `human_modified` +is sticky for the life of the run (set when a fork enters execution, surfaced as header/ +stage chips in the dossier, a board-card chip, and a Review Queue link-chip — the +run-list-level visibility the product AC requires). + +**Run reopening.** `Run_State::reopen()` is the atomic terminal→running gate for re-run +spend; it RE-SNAPSHOTS `ceiling_usd` from the current setting (a deliberate re-run +adopts the operator's current per-run policy, exactly like a new run) and never resets +reserved/settled — re-runs are new spend on the same run. Handlers always restore a +terminal status (or record failed/halted) so the run-reaper's stuck-running sweep stays +meaningful; jobs hold the global generation lock and ride the M1 status transient, so +board/dossier polling shows queued → pickup → result and the existing R2/R3 orphan +recovery covers dead re-run processes unchanged. + +**Eligibility (guardrail 5).** Posts with status publish/future/private freeze their +run server-side (and `Publisher` refuses to touch them: re-run output refreshes only +UNPUBLISHED posts, preserving their status — re-runs never publish). Editable stages +are the item-scoped writer chat stages (outline/draft/polish); review/publish re-run +via rebuild (their inputs are derived), run-level and image stages are excluded with +visible in-UI reasons. Phase-2b stages inherit the mechanism when they exist as +item-scoped chat calls. + +**F2/F3 (QA M2):** the sidebar consolidates per-stage model/pv/role; image (and other +log-only) stages render from generation_log + the `_prautoblogger_image_role` +attachment set — wiring the dossier to real data was deliberately chosen over teaching +the live image pipeline to write run_stages (that belongs to the Phase-2b restructure). + ## Cross-System LLM Budget Coordination PRAutoBlogger and Peptide News both call OpenRouter and may share a single API key / billing account. Their combined spend should be considered when setting per-plugin budgets. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf4fbf..88f1332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,77 @@ All notable changes to PRAutoBlogger will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project uses [Semantic Versioning](https://semver.org/). +## [0.20.0] - 2026-06-12 + +### Added +- **Edit + single-step re-run (Phase 2 admin M3)** under the five CPO guardrails + (cpo seq 8, hard AC). From the Article Dossier, eligible stages can have their + input edited and re-run as QUEUED background jobs (chained-cron — the UI never + implies synchronous execution; board + dossier polling shows queued → pickup → + result). + - **Immutable input versions (guardrail 1).** Edits fork into the new INSERT-only + `prautoblogger_stage_inputs` table (monotonic versions per stage scope); the + executed original in `generation_log.request_json` is never overwritten. The + editor locks message structure (count/roles) so every fork stays replayable. + - **Visible human-modified audit (guardrail 2).** `run_stages.human_modified` + + `run_decisions.human_modified` set when an edited fork enters execution (sticky, + never cleared); surfaced as dossier header + stage chips, a board-card chip, and + decisions recorded while rebuilding a human-modified item are flagged too. + - **Explicit downstream invalidation (guardrail 3).** Re-running a stage marks all + downstream chain stages `stale` (new column — rows STAY 'done', so the resume + machinery can never silently auto-re-run them). Stale chips render per stage, in + the dossier header, and as a Review Queue warning chip linking to the dossier + (stale state visible at publish). Downstream re-runs are deliberate: per-stage + "Re-run from here" rebuilds the target + downstream from current upstream + snapshots via the existing worker resume path. + - **Cost governor applies to re-runs (guardrail 4).** Replays go through the normal + provider seam: reserve-before-call against the SAME run ledger row. + `Run_State::reopen()` re-snapshots the ceiling from the current setting and never + resets accumulated spend. The dossier shows a Run Spend card + per-panel spend + strip and warns when committed spend reaches the ceiling fraction (filterable, + `prautoblogger_rerun_ceiling_warn_fraction`, default 0.8). + - **Published posts are frozen (guardrail 5).** publish/future/private lock the run + server-side (not just hidden UI); `Publisher` refreshes re-run output onto + UNPUBLISHED posts only, preserving their status — a re-run never publishes. +- **B1 — `request_json` persisted on every chat LLM call** (QA M2 F1). The provider + records the outgoing request BODY pre-dispatch (process-scoped recorder, consume-once); + `log_api_call()` writes it to the row. Authorization headers can never be recorded + (built separately; body keys whitelisted — covered by tests). Retention rides the + existing `prautoblogger_request_json_retention_days` prune; the dossier raw-trace + "Request payload" section now renders for new runs. +- **Idea seeds.** `Article_Worker` persists the exact `Article_Idea` payload once per + item (stage_inputs, source='seed') so re-run-from-here reconstructs the precise idea + (re-deriving from post fields could break the item-key hash and duplicate a post). +- **Dossier sidebar consolidation (QA M2 F2).** New "Models & Prompts" card: per-stage + model + prompt version + agent role at a glance, plus the "Run Spend" card. +- **Image sections wired to real data (QA M2 F3).** image_a/image_b (and every + log-only stage: llm_research, image_prompt_rewrite, …) now render from their actual + generation_log rows, with the post's pipeline attachments (featured/og/square via + `_prautoblogger_image_role`) shown as an image grid — chosen/featured highlighted. +- **Schema (db 1.3.0).** `run_stages` + `human_modified`/`stale`; `run_decisions` + + `human_modified`; new `stage_inputs` table. dbDelta-idempotent via the standard + self-healing migration path (admin_init + cron init); half-migrated sites degrade + gracefully (done() retries the legacy statement; M3 features no-op until migrated). + Uninstall drops the new table and clears the two new cron hooks. + +### Changed +- **`PRAutoBlogger_Run_Stage_State` split** (300-line cap, M2 pattern): pipeline write + bodies moved verbatim to `Run_Stage_Writes` (public API unchanged via thin proxies); + operator-action mutations (restart / mark_stale / demote_to_pending) isolated in + `Run_Stage_Rerun_State` — the ONLY code allowed to demote a done row. `done()` now + clears `stale` on fresh output. +- **Dossier is item-scoped** (M2 correctness fix): stage rows filter to THIS post's + item (idea-hash meta), and gen_log rows linked to OTHER posts of a multi-article run + are excluded (unlinked run-level rows are kept). +- **`Publisher` idempotency short-circuit** now refreshes existing UNPUBLISHED posts + with re-run content (title/content/verdict meta; status untouched) instead of + silently discarding regenerated output. Published posts remain untouched. +- Review Queue rows show a stale-stages warning chip linking to the dossier; board + cards show a "Human-modified" chip (single batched query, no N+1). + +### Fixed +- Dossier "Request payload" raw trace was always empty (request_json never populated — + QA M2 F1); image sections always rendered the absent state (QA M2 F3). ## [0.19.4] - 2026-06-12 ### Added diff --git a/assets/css/dossier-edit.css b/assets/css/dossier-edit.css new file mode 100644 index 0000000..ea6bc4b --- /dev/null +++ b/assets/css/dossier-edit.css @@ -0,0 +1,240 @@ +/** + * PRAutoBlogger dossier — edit + re-run styles (M3, v0.20.0). + * Extends dossier.css (kept separate: dossier.css predates the + * 300-line discipline and is not grown further). + */ + +/* ── Audit chips (stale / human-edited / attempt) ─────────────────── */ +.prab-chip { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + line-height: 1.6; + vertical-align: middle; +} + +.prab-chip--stale { + background: #fcf0e4; + color: #8a4a0b; + border: 1px solid #e8c9a8; +} + +.prab-chip--human { + background: #e9f2f4; + color: #1b5e6b; + border: 1px solid #b8d8de; +} + +.prab-chip--attempt { + background: #f0f0f1; + color: #50575e; + border: 1px solid #dcdcde; +} + +.prab-chip--chosen { + background: #edf7ed; + color: #1e6b2e; + border: 1px solid #b9dfc0; +} + +.prab-chip--header { + margin-left: 6px; + font-size: 12px; +} + +.prab-stage--stale { + border-left: 3px solid #d98c2b; +} + +/* ── Stage footer actions ─────────────────────────────────────────── */ +.prab-stage-rerun-footer { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed #e2dcd2; +} + +.prab-edit-unavailable { + font-size: 12px; + color: #777; + font-style: italic; + max-width: 46em; +} + +.prab-rerun-from--stale { + border-color: #d98c2b; + color: #8a4a0b; +} + +/* ── Edit panel ───────────────────────────────────────────────────── */ +.prab-edit-panel { + margin-top: 12px; + padding: 14px 16px; + background: #fbf8f2; + border: 1px solid #e2dcd2; + border-radius: 6px; +} + +.prab-edit-copy { + font-size: 13px; + color: #50575e; + max-width: 60em; +} + +.prab-edit-spend { + font-size: 12px; + color: #50575e; +} + +.prab-edit-spend--warn, +.prab-spend-warning { + color: #8a2a0b; + font-weight: 600; +} + +.prab-run-spend--warn { + border-left: 3px solid #c9501f; +} + +.prab-edit-model { + font-size: 12px; + color: #777; + font-family: Consolas, Monaco, monospace; +} + +.prab-edit-message { + margin-bottom: 10px; +} + +.prab-edit-role { + display: block; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8c8f94; + margin-bottom: 3px; +} + +.prab-edit-textarea { + width: 100%; + font-family: Consolas, Monaco, monospace; + font-size: 12px; + line-height: 1.5; +} + +.prab-edit-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 8px; +} + +.prab-edit-forkinfo { + font-size: 12px; + color: #777; +} + +.prab-edit-feedback { + font-size: 12px; + color: #1e6b2e; +} + +.prab-edit-feedback--error { + color: #b32d2e; +} + +/* ── Image grid (F3) ──────────────────────────────────────────────── */ +.prab-image-grid { + display: flex; + gap: 14px; + flex-wrap: wrap; + margin: 10px 0; +} + +.prab-image-card { + margin: 0; + max-width: 220px; +} + +.prab-image-card img { + width: 100%; + height: auto; + border-radius: 4px; + border: 1px solid #e2dcd2; +} + +.prab-image-card--chosen img { + border: 2px solid #1b8a92; +} + +.prab-image-card figcaption { + font-size: 12px; + color: #50575e; + margin-top: 4px; +} + +/* ── Sidebar models card (F2) ─────────────────────────────────────── */ +.prab-models-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.prab-models-table th { + text-align: left; + font-weight: 600; + padding: 4px 6px 4px 0; + vertical-align: top; + color: #50575e; + width: 38%; +} + +.prab-models-table td { + padding: 4px 0; +} + +.prab-models-model { + display: block; + font-family: Consolas, Monaco, monospace; + font-size: 11px; + word-break: break-all; +} + +.prab-models-meta { + display: block; + color: #8c8f94; + font-size: 11px; +} + +/* ── Board card human-modified dot ────────────────────────────────── */ +.prab-card-human-chip { + display: inline-block; + margin-top: 4px; + padding: 1px 7px; + border-radius: 9px; + font-size: 10px; + font-weight: 700; + background: #e9f2f4; + color: #1b5e6b; + border: 1px solid #b8d8de; +} + +/* ── Review queue stale warning ───────────────────────────────────── */ +.prab-review-stale-chip { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 9px; + font-size: 10px; + font-weight: 700; + background: #fcf0e4; + color: #8a4a0b; + border: 1px solid #e8c9a8; +} diff --git a/assets/js/board.js b/assets/js/board.js index 38f697a..a58f31a 100644 --- a/assets/js/board.js +++ b/assets/js/board.js @@ -69,6 +69,11 @@ html += '
' + escHtml( card.title || cfg.strings.empty ) + '
'; + // v0.20.0: human-modified runs are visually distinct at run-list level. + if ( card.human_modified ) { + html += '' + escHtml( cfg.strings.humanModified || 'Human-modified' ) + ''; + } + if ( card.cost_total ) { html += '
'; html += escHtml( formatCost( card.cost_total ) ); diff --git a/assets/js/dossier-edit.js b/assets/js/dossier-edit.js new file mode 100644 index 0000000..09ac2ec --- /dev/null +++ b/assets/js/dossier-edit.js @@ -0,0 +1,235 @@ +/** + * PRAutoBlogger Article Dossier — edit + re-run actions (M3, v0.20.0). + * + * Chained-cron UX contract (CPO hard constraint): clicking a re-run + * button QUEUES a background job — the UI says "queued", starts the + * stage-status poll, and renders pickup/result as the rows change. + * Nothing here implies synchronous execution. + * + * Vanilla JS, no build step. Config injected via wp_localize_script + * ('prabDossierEdit': ajaxUrl, nonce, postId, pollInterval, strings). + * + * @see admin/class-dossier-actions.php — The endpoints this calls. + * @see admin/class-dossier-page.php — Enqueue + localization. + */ +( function () { + 'use strict'; + + var cfg = window.prabDossierEdit || null; + + function post( action, fields ) { + var data = new window.FormData(); + data.append( 'action', action ); + data.append( 'nonce', cfg.nonce ); + data.append( 'post_id', cfg.postId ); + Object.keys( fields || {} ).forEach( function ( key ) { + data.append( key, fields[ key ] ); + } ); + return window.fetch( cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: data } ) + .then( function ( res ) { return res.json(); } ); + } + + function feedback( panel, message, isError ) { + var el = panel.querySelector( '[data-feedback]' ); + if ( el ) { + el.textContent = message || ''; + el.classList.toggle( 'prab-edit-feedback--error', !! isError ); + } + } + + function sectionFor( el ) { + var node = el; + while ( node && ! node.classList.contains( 'prab-dossier-section--stage' ) ) { + node = node.parentElement; + } + return node; + } + + // ── Stage-status polling (queued → pickup → result) ──────────────── + var pollTimer = null; + var sawActivity = false; + + function applyStageState( state ) { + var selector = '.prab-dossier-section--stage[data-stage="' + state.stage + '"][data-agent-role="' + state.agent_role + '"]'; + var section = document.querySelector( selector ); + if ( ! section ) { + return; + } + var pill = section.querySelector( '.prab-stage-status-pill' ); + if ( pill ) { + pill.className = 'prab-stage-status-pill prab-stage-status-pill--' + state.status; + pill.textContent = state.status.charAt( 0 ).toUpperCase() + state.status.slice( 1 ); + } + var staleChip = section.querySelector( '[data-chip="stale"]' ); + if ( staleChip ) { + staleChip.hidden = ! state.stale; + } + section.classList.toggle( 'prab-stage--stale', !! state.stale ); + var humanChip = section.querySelector( '[data-chip="human"]' ); + if ( humanChip ) { + humanChip.hidden = ! state.human_modified; + } + } + + function poll() { + post( 'prautoblogger_dossier_stage_status', {} ).then( function ( res ) { + if ( ! res || ! res.success || ! res.data ) { + return; + } + var anyRunning = false; + var anyStale = false; + var anyHuman = false; + ( res.data.stages || [] ).forEach( function ( state ) { + applyStageState( state ); + if ( 'running' === state.status || 'pending' === state.status ) { + anyRunning = true; + } + anyStale = anyStale || !! state.stale; + anyHuman = anyHuman || !! state.human_modified; + } ); + var headerHuman = document.querySelector( '[data-header-chip="human"]' ); + if ( headerHuman ) { + headerHuman.hidden = ! anyHuman; + } + var headerStale = document.querySelector( '[data-header-chip="stale"]' ); + if ( headerStale ) { + headerStale.hidden = ! anyStale; + } + + var terminal = -1 === [ 'running', 'pending' ].indexOf( res.data.run_status ); + if ( anyRunning || ! terminal ) { + sawActivity = true; + return; + } + // Run settled after activity we watched: re-render the dossier + // fresh (server-rendered outputs, receipts, fork info). + if ( sawActivity ) { + window.clearInterval( pollTimer ); + pollTimer = null; + window.location.reload(); + } + } ).catch( function () { /* transient poll errors are ignored */ } ); + } + + function startPolling() { + sawActivity = true; + if ( ! pollTimer ) { + pollTimer = window.setInterval( poll, cfg.pollInterval * 1000 ); + } + } + + // ── Wiring ────────────────────────────────────────────────────────── + document.addEventListener( 'DOMContentLoaded', function () { + if ( ! cfg ) { + return; + } + + // Edit panel toggles. + document.querySelectorAll( '.prab-edit-toggle' ).forEach( function ( btn ) { + btn.addEventListener( 'click', function () { + var panel = document.getElementById( btn.getAttribute( 'aria-controls' ) ); + if ( ! panel ) { + return; + } + var open = btn.getAttribute( 'aria-expanded' ) === 'true'; + panel.hidden = open; + btn.setAttribute( 'aria-expanded', open ? 'false' : 'true' ); + } ); + } ); + + // Save fork. + document.querySelectorAll( '.prab-edit-save' ).forEach( function ( btn ) { + btn.addEventListener( 'click', function () { + var panel = btn.closest( '.prab-edit-panel' ); + var messages = []; + panel.querySelectorAll( '.prab-edit-textarea' ).forEach( function ( area ) { + messages.push( { role: area.getAttribute( 'data-role' ), content: area.value } ); + } ); + btn.disabled = true; + feedback( panel, cfg.strings.saving, false ); + post( 'prautoblogger_dossier_save_input', { + stage: panel.getAttribute( 'data-stage' ), + agent_role: panel.getAttribute( 'data-agent-role' ), + messages: JSON.stringify( messages ) + } ).then( function ( res ) { + btn.disabled = false; + if ( res && res.success ) { + feedback( panel, res.data.message, false ); + var rerunBtn = panel.querySelector( '.prab-edit-rerun' ); + if ( rerunBtn ) { + rerunBtn.disabled = false; + } + var info = panel.querySelector( '[data-forkinfo]' ); + if ( info && res.data.version ) { + info.textContent = 'v' + res.data.version; + } + } else { + feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true ); + } + } ).catch( function () { + btn.disabled = false; + feedback( panel, cfg.strings.error, true ); + } ); + } ); + } ); + + // Replay latest fork (queued). + document.querySelectorAll( '.prab-edit-rerun' ).forEach( function ( btn ) { + btn.addEventListener( 'click', function () { + var panel = btn.closest( '.prab-edit-panel' ); + if ( ! window.confirm( cfg.strings.confirmReplay ) ) { + return; + } + btn.disabled = true; + post( 'prautoblogger_dossier_rerun_stage', { + stage: panel.getAttribute( 'data-stage' ), + agent_role: panel.getAttribute( 'data-agent-role' ) + } ).then( function ( res ) { + if ( res && res.success ) { + feedback( panel, res.data.message, false ); + startPolling(); + } else { + btn.disabled = false; + feedback( panel, ( res && res.data && res.data.message ) || cfg.strings.error, true ); + } + } ).catch( function () { + btn.disabled = false; + feedback( panel, cfg.strings.error, true ); + } ); + } ); + } ); + + // Re-run from here (queued rebuild). + document.querySelectorAll( '.prab-rerun-from' ).forEach( function ( btn ) { + btn.addEventListener( 'click', function () { + var section = sectionFor( btn ); + if ( ! window.confirm( cfg.strings.confirmRerunFrom ) ) { + return; + } + btn.disabled = true; + btn.textContent = cfg.strings.queued; + post( 'prautoblogger_dossier_rerun_from', { + stage: btn.getAttribute( 'data-stage' ), + agent_role: section ? section.getAttribute( 'data-agent-role' ) : '' + } ).then( function ( res ) { + if ( res && res.success ) { + startPolling(); + } else { + btn.disabled = false; + btn.textContent = cfg.strings.rerunFromLabel; + window.alert( ( res && res.data && res.data.message ) || cfg.strings.error ); + } + } ).catch( function () { + btn.disabled = false; + btn.textContent = cfg.strings.rerunFromLabel; + window.alert( cfg.strings.error ); + } ); + } ); + } ); + + // A stage already running (e.g. revisit mid-job): poll from load. + if ( document.querySelector( '.prab-stage-status-pill--running' ) ) { + startPolling(); + } + } ); +}() ); diff --git a/includes/admin/class-board-data-provider.php b/includes/admin/class-board-data-provider.php index d2fbf15..1b4c7ec 100644 --- a/includes/admin/class-board-data-provider.php +++ b/includes/admin/class-board-data-provider.php @@ -127,7 +127,7 @@ class PRAutoBlogger_Board_Data_Provider { ); } - return $cards; + return $this->flag_human_modified( $cards ); } /** @@ -183,6 +183,48 @@ class PRAutoBlogger_Board_Data_Provider { ); } + return $this->flag_human_modified( $cards ); + } + + /** + * Set 'human_modified' on each card in ONE batched query (v0.20.0 — + * CPO product AC: human-modified runs are visually distinct at the + * run-list level). No per-card queries; self-healing on a missing + * table/column (flag simply stays false). + * + * @param array> $cards Column cards. + * @return array> Cards with the flag set. + */ + private function flag_human_modified( array $cards ): array { + $run_ids = array(); + foreach ( $cards as $i => $card ) { + $cards[ $i ]['human_modified'] = false; + if ( ! empty( $card['run_id'] ) ) { + $run_ids[] = (string) $card['run_id']; + } + } + if ( empty( $run_ids ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) { + return $cards; + } + global $wpdb; + $table = PRAutoBlogger_Run_Stage_State::table_name(); + $placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list. + $flagged = $wpdb->get_col( + $wpdb->prepare( + "SELECT DISTINCT run_id FROM {$table} WHERE human_modified = 1 AND run_id IN ({$placeholders})", + $run_ids + ) + ); + if ( ! is_array( $flagged ) || empty( $flagged ) ) { + return $cards; + } + $flagged = array_flip( array_map( 'strval', $flagged ) ); + foreach ( $cards as $i => $card ) { + if ( isset( $flagged[ (string) ( $card['run_id'] ?? '' ) ] ) ) { + $cards[ $i ]['human_modified'] = true; + } + } return $cards; } } diff --git a/includes/admin/class-board-page.php b/includes/admin/class-board-page.php index 02ac089..4b1e3c0 100644 --- a/includes/admin/class-board-page.php +++ b/includes/admin/class-board-page.php @@ -152,6 +152,7 @@ class PRAutoBlogger_Board_Page { 'edit' => __( 'Edit', 'prautoblogger' ), 'viewLog' => __( 'View Log', 'prautoblogger' ), 'pollError' => __( 'Board update failed — retrying.', 'prautoblogger' ), + 'humanModified' => __( 'Human-modified', 'prautoblogger' ), ), ) ); diff --git a/includes/admin/class-dossier-actions.php b/includes/admin/class-dossier-actions.php new file mode 100644 index 0000000..bce0a65 --- /dev/null +++ b/includes/admin/class-dossier-actions.php @@ -0,0 +1,299 @@ + pickup -> result. + * Who triggers it: assets/js/dossier-edit.js via admin-ajax.php; + * registered in class-prautoblogger.php. + * Dependencies: Rerun_Eligibility, Stage_Input_Store, Stage_Replay + * (decode), Rerun_Job_Support/Executor, Run_Stage_State. + * + * @see assets/js/dossier-edit.js — The frontend caller. + * @see core/class-rerun-executor.php — The cron handlers jobs land in. + * @see ARCHITECTURE.md #24 — Edit + re-run design. + */ +class PRAutoBlogger_Dossier_Actions { + + /** Nonce action shared by all four endpoints. */ + public const NONCE_ACTION = 'prautoblogger_dossier_actions'; + + /** + * AJAX: save an edited stage input as a new immutable fork version. + * + * The fork body is the stage's base request (latest fork, else the + * recorded request_json) with ONLY the message contents substituted — + * roles, message count, model and call options are not editable, so + * a fork is always a well-formed replayable chat body. Prompt text + * is stored raw (it is LLM input, never rendered unescaped). + * + * Side effects: one stage_inputs INSERT. + * + * @return void Sends JSON. + */ + public function on_save_input(): void { + $request = $this->authorize_and_resolve(); + + $check = PRAutoBlogger_Rerun_Eligibility::check( $request['run_id'], $request['post_id'] ); + if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $request['stage'] ) || ! $check['ok'] ) { + $reason = $check['ok'] ? __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) : $check['reason']; + wp_send_json_error( array( 'message' => $reason ), 400 ); + return; + } + + $base_body = $this->resolve_base_body( $request ); + if ( null === $base_body ) { + wp_send_json_error( array( 'message' => __( 'No persisted input exists for this stage.', 'prautoblogger' ) ), 400 ); + return; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw prompt text for the LLM; structure-validated below, stored raw, escaped at render. + $raw = isset( $_POST['messages'] ) ? wp_unslash( (string) $_POST['messages'] ) : ''; + $messages = json_decode( $raw, true ); + $merged = $this->merge_messages( $base_body, is_array( $messages ) ? $messages : null ); + if ( null === $merged ) { + wp_send_json_error( array( 'message' => __( 'Edited messages must match the original structure (same count and roles, non-empty contents).', 'prautoblogger' ) ), 400 ); + return; + } + + $user = wp_get_current_user(); + $version = PRAutoBlogger_Stage_Input_Store::save_fork( + $request['run_id'], + $request['stage'], + $request['agent_role'], + $request['item_key'], + (string) wp_json_encode( $merged ), + (string) $user->user_login + ); + if ( null === $version ) { + wp_send_json_error( array( 'message' => __( 'The edited input could not be saved.', 'prautoblogger' ) ), 500 ); + return; + } + + wp_send_json_success( + array( + 'version' => $version, + 'message' => sprintf( + /* translators: %d: fork version number. */ + __( 'Saved as version %d. The original input is preserved unchanged; re-running this stage will use your edited version and mark all downstream stages stale.', 'prautoblogger' ), + $version + ), + ) + ); + } + + /** + * AJAX: queue a single-stage replay of the latest edited fork. + * + * Validates, then ONLY schedules — chained-cron semantics. The UI + * copy never implies synchronous execution. + * + * @return void Sends JSON. + */ + public function on_rerun_stage(): void { + $request = $this->authorize_and_resolve(); + + $check = PRAutoBlogger_Rerun_Eligibility::check_replay( + $request['run_id'], + $request['post_id'], + $request['stage'], + $request['agent_role'], + $request['item_key'] + ); + if ( ! $check['ok'] ) { + wp_send_json_error( array( 'message' => $check['reason'] ), 400 ); + return; + } + + PRAutoBlogger_Rerun_Job_Support::queue( + PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, + array( $request['run_id'], $request['post_id'], $request['stage'], $request['agent_role'], $request['item_key'] ), + sprintf( + /* translators: %s: stage display label. */ + __( 'Queued: re-running %s with the edited input…', 'prautoblogger' ), + PRAutoBlogger_Stage_Display_Map::label( $request['stage'] ) + ) + ); + + wp_send_json_success( + array( + 'queued' => true, + 'message' => __( 'Re-run queued. The pipeline will pick it up shortly — progress appears here and on the board.', 'prautoblogger' ), + ) + ); + } + + /** + * AJAX: queue re-run-from-here (rebuild this stage + downstream). + * + * @return void Sends JSON. + */ + public function on_rerun_from(): void { + $request = $this->authorize_and_resolve(); + + $check = PRAutoBlogger_Rerun_Eligibility::check_rebuild( + $request['run_id'], + $request['post_id'], + $request['stage'], + $request['item_key'] + ); + if ( ! $check['ok'] ) { + wp_send_json_error( array( 'message' => $check['reason'] ), 400 ); + return; + } + + PRAutoBlogger_Rerun_Job_Support::queue( + PRAutoBlogger_Rerun_Executor::REBUILD_ACTION, + array( $request['run_id'], $request['post_id'], $request['stage'], $request['item_key'] ), + sprintf( + /* translators: %s: stage display label. */ + __( 'Queued: re-running the pipeline from %s…', 'prautoblogger' ), + PRAutoBlogger_Stage_Display_Map::label( $request['stage'] ) + ) + ); + + wp_send_json_success( + array( + 'queued' => true, + 'message' => __( 'Re-run from here queued. Stages will rebuild in sequence — progress appears here and on the board.', 'prautoblogger' ), + ) + ); + } + + /** + * AJAX: read-only stage-status poll for the dossier. + * + * @return void Sends JSON: run status, per-stage state, live message. + */ + public function on_stage_status(): void { + $request = $this->authorize_and_resolve( false ); + + $stages = array(); + foreach ( PRAutoBlogger_Run_Stage_State::stages_for_item( $request['run_id'], $request['item_key'] ) as $row ) { + $stages[] = array( + 'stage' => (string) ( $row['stage'] ?? '' ), + 'agent_role' => (string) ( $row['agent_role'] ?? '' ), + 'status' => (string) ( $row['status'] ?? '' ), + 'stale' => ! empty( $row['stale'] ), + 'human_modified' => ! empty( $row['human_modified'] ), + 'attempt' => (int) ( $row['attempt'] ?? 1 ), + 'finished_at' => (string) ( $row['finished_at'] ?? '' ), + ); + } + + $live = get_transient( PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT ); + + wp_send_json_success( + array( + 'run_status' => (string) ( PRAutoBlogger_Run_State::get_status( $request['run_id'] ) ?? '' ), + 'stages' => $stages, + 'live' => is_array( $live ) ? $live : null, + ) + ); + } + + /** + * Shared gate: nonce + capability, then derive run identity from the + * POSTed post_id server-side. Sends a JSON error (and exits) on any + * failure. + * + * @param bool $require_stage Whether the request must name a stage row. + * @return array{post_id: int, run_id: string, item_key: string, stage: string, agent_role: string} + */ + private function authorize_and_resolve( bool $require_stage = true ): array { + check_ajax_referer( self::NONCE_ACTION, 'nonce' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ), 403 ); + } + + $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; + $run_id = $post_id > 0 ? (string) get_post_meta( $post_id, '_prautoblogger_run_id', true ) : ''; + if ( 0 === $post_id || '' === $run_id ) { + wp_send_json_error( array( 'message' => __( 'No generation run is recorded for this article.', 'prautoblogger' ) ), 400 ); + } + $idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true ); + $item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : ''; + + $stage = isset( $_POST['stage'] ) ? sanitize_key( (string) wp_unslash( $_POST['stage'] ) ) : ''; + $agent_role = isset( $_POST['agent_role'] ) ? sanitize_key( (string) wp_unslash( $_POST['agent_role'] ) ) : ''; + if ( $require_stage && '' === $stage ) { + wp_send_json_error( array( 'message' => __( 'No stage specified.', 'prautoblogger' ) ), 400 ); + } + + return array( + 'post_id' => $post_id, + 'run_id' => $run_id, + 'item_key' => $item_key, + 'stage' => $stage, + 'agent_role' => $agent_role, + ); + } + + /** + * The base body an edit forks from: latest saved fork, else the + * stage's recorded request_json. + * + * @param array $request Resolved request context. + * @return array|null Decoded body, or null when absent. + */ + private function resolve_base_body( array $request ): ?array { + $fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $request['run_id'], $request['stage'], $request['agent_role'], $request['item_key'] ); + if ( null !== $fork && ! empty( $fork['request_json'] ) ) { + return PRAutoBlogger_Stage_Replay::decode_body( (string) $fork['request_json'] ); + } + $log_rows = ( new PRAutoBlogger_Dossier_Gen_Log_Query() )->get_by_run( $request['run_id'] ); + $rows = $log_rows[ $request['stage'] ] ?? array(); + foreach ( array_reverse( $rows ) as $row ) { + if ( ! empty( $row['request_json'] ) ) { + $body = PRAutoBlogger_Stage_Replay::decode_body( (string) $row['request_json'] ); + if ( null !== $body ) { + return $body; + } + } + } + return null; + } + + /** + * Substitute edited message contents into the base body. Structure + * is locked: same message count, same roles, all contents non-empty + * strings — so every fork stays a replayable chat body. + * + * @param array $base Decoded base body. + * @param array|null $messages Edited messages payload. + * @return array|null Merged body, or null when invalid. + */ + private function merge_messages( array $base, ?array $messages ): ?array { + if ( null === $messages || count( $messages ) !== count( $base['messages'] ) ) { + return null; + } + $merged = $base; + foreach ( array_values( $messages ) as $i => $message ) { + if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] ) + || ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) { + return null; + } + if ( ( $base['messages'][ $i ]['role'] ?? null ) !== $message['role'] ) { + return null; + } + if ( '' === trim( $message['content'] ) ) { + return null; + } + $merged['messages'][ $i ]['content'] = $message['content']; + } + return $merged; + } +} diff --git a/includes/admin/class-dossier-data-assembler.php b/includes/admin/class-dossier-data-assembler.php index 23a8359..718563a 100644 --- a/includes/admin/class-dossier-data-assembler.php +++ b/includes/admin/class-dossier-data-assembler.php @@ -16,6 +16,15 @@ declare(strict_types=1); * Legacy posts (no run row) return ['has_run' => false] -- never notices. * Amortized research rows (pv=null, role='') render gracefully. * + * v0.20.0 (M3): stage rows are filtered to THIS post's item (item_key + * from the idea-hash meta; multi-article runs no longer collide) and + * gen_log rows exclude entries linked to OTHER posts; per-stage edit/ + * re-run affordance data + run spend ride the view model + * (Dossier_Rerun_Panel_Data); log-only stages (image_a/image_b/ + * llm_research/... -- generation_log rows with no run_stages row) and + * the post's pipeline attachments are exposed so the image sections + * render from their REAL data sources (QA M2 F3). + * * Triggered by: PRAutoBlogger_Dossier_Page::render_page(). * Dependencies: PRAutoBlogger_Dossier_Gen_Log_Query, PRAutoBlogger_Stage_Display_Map, * PRAutoBlogger_Run_Stage_State. @@ -55,6 +64,16 @@ class PRAutoBlogger_Dossier_Data_Assembler { 'stages' => array(), 'decisions' => array(), 'gen_log_index' => array(), + 'item_key' => '', + 'eligibility' => array( + 'ok' => false, + 'reason' => '', + ), + 'spend' => PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( null ), + 'human_modified_any' => false, + 'stale_any' => false, + 'log_only_stages' => array(), + 'images' => array(), ); if ( $post_id <= 0 ) { @@ -82,11 +101,40 @@ class PRAutoBlogger_Dossier_Data_Assembler { return $base; } + $idea_hash = (string) get_post_meta( $post_id, '_prautoblogger_idea_hash', true ); + $item_key = '' !== $idea_hash ? 'idea:' . $idea_hash : ''; + $base['item_key'] = $item_key; + $base['has_run'] = true; $base['run'] = $run; - $base['stages'] = $this->get_stage_rows( $run_id ); + $base['stages'] = $this->get_stage_rows( $run_id, $item_key ); $base['decisions'] = $this->get_decision_rows( $run_id ); - $base['gen_log_index'] = $this->log_query->get_by_run( $run_id ); + $base['gen_log_index'] = $this->filter_log_for_post( $this->log_query->get_by_run( $run_id ), $post_id ); + $base['eligibility'] = PRAutoBlogger_Rerun_Eligibility::check( $run_id, $post_id ); + $base['spend'] = PRAutoBlogger_Dossier_Rerun_Panel_Data::spend( $run ); + + foreach ( $base['stages'] as $role_key => $stage_row ) { + $stage_name = (string) ( $stage_row['stage'] ?? '' ); + $rerun = PRAutoBlogger_Dossier_Rerun_Panel_Data::for_stage( + $stage_row, + $base['gen_log_index'][ $stage_name ] ?? array(), + $run_id, + $base['eligibility'] + ); + $base['stages'][ $role_key ]['rerun'] = $rerun; + $base['human_modified_any'] = $base['human_modified_any'] || $rerun['human_modified']; + $base['stale_any'] = $base['stale_any'] || $rerun['stale']; + } + + // QA M2 F3: stages that exist only in generation_log (image_a/ + // image_b, llm_research, image_prompt_rewrite, ...) render from + // their real source instead of silently disappearing. + $run_stage_names = array(); + foreach ( $base['stages'] as $stage_row ) { + $run_stage_names[ (string) ( $stage_row['stage'] ?? '' ) ] = true; + } + $base['log_only_stages'] = array_values( array_diff( array_keys( $base['gen_log_index'] ), array_keys( $run_stage_names ) ) ); + $base['images'] = PRAutoBlogger_Dossier_Image_Data::for_post( $post_id ); return $base; } @@ -112,12 +160,14 @@ class PRAutoBlogger_Dossier_Data_Assembler { } /** - * Fetch all run_stages rows for a run, indexed by stage name. + * Fetch run_stages rows for a run, indexed by stage name — scoped to + * one item (+ run-level rows) when an item key is known. * - * @param string $run_id Pipeline run UUID. + * @param string $run_id Pipeline run UUID. + * @param string $item_key Item key ('' = no scoping, M2 behavior). * @return array> Stage rows keyed by stage name. */ - private function get_stage_rows( string $run_id ): array { + private function get_stage_rows( string $run_id, string $item_key = '' ): array { if ( ! PRAutoBlogger_Run_Stage_State::is_available() ) { return array(); } @@ -126,11 +176,22 @@ class PRAutoBlogger_Dossier_Data_Assembler { return array(); } $table = PRAutoBlogger_Run_Stage_State::table_name(); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching - $rows = $wpdb->get_results( - $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ), - ARRAY_A - ); + // v0.20.0: scope to THIS post's item + run-level rows. When the + // idea-hash meta is absent (pre-v0.18 posts) fall back to the + // unfiltered M2 behavior (single-item runs are unaffected). + if ( '' !== $item_key ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC", $run_id, $item_key ), + ARRAY_A + ); + } else { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $rows = $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE run_id = %s ORDER BY id ASC", $run_id ), + ARRAY_A + ); + } $index = array(); foreach ( ( $rows ?? array() ) as $row ) { @@ -145,6 +206,29 @@ class PRAutoBlogger_Dossier_Data_Assembler { return $index; } + /** + * Drop gen_log rows linked to OTHER posts (multi-article runs share + * one run_id). Rows with no post linkage (NULL/0 — run-level work + * and pre-link entries) are kept. + * + * @param array>> $log_index Rows keyed by stage. + * @param int $post_id This dossier's post. + * @return array>> + */ + private function filter_log_for_post( array $log_index, int $post_id ): array { + $filtered = array(); + foreach ( $log_index as $stage => $rows ) { + foreach ( $rows as $row ) { + $row_post = (int) ( $row['post_id'] ?? 0 ); + if ( 0 !== $row_post && $row_post !== $post_id ) { + continue; + } + $filtered[ $stage ][] = $row; + } + } + return $filtered; + } + /** * Extract the text output from a run_stages meta_json blob. * Returns null when meta_json is NULL (pruned) or absent. diff --git a/includes/admin/class-dossier-image-data.php b/includes/admin/class-dossier-image-data.php new file mode 100644 index 0000000..185aba9 --- /dev/null +++ b/includes/admin/class-dossier-image-data.php @@ -0,0 +1,82 @@ + + */ + public static function for_post( int $post_id ): array { + $featured_id = (int) get_post_thumbnail_id( $post_id ); + $children = get_children( + array( + 'post_parent' => $post_id, + 'post_type' => 'attachment', + 'numberposts' => 12, + ) + ); + + $images = array(); + $seen = array(); + foreach ( (array) $children as $attachment ) { + $att_id = (int) ( $attachment->ID ?? 0 ); + if ( $att_id <= 0 ) { + continue; + } + $role = (string) get_post_meta( $att_id, '_prautoblogger_image_role', true ); + if ( '' === $role && $att_id !== $featured_id ) { + continue; // Not a pipeline attachment. + } + $url = (string) wp_get_attachment_image_url( $att_id, 'medium' ); + if ( '' === $url ) { + continue; + } + $seen[ $att_id ] = true; + $images[] = array( + 'id' => $att_id, + 'role' => '' !== $role ? $role : 'featured', + 'url' => $url, + 'is_featured' => $att_id === $featured_id, + ); + } + // Featured image attached elsewhere (not a child) still renders. + if ( $featured_id > 0 && ! isset( $seen[ $featured_id ] ) ) { + $url = (string) wp_get_attachment_image_url( $featured_id, 'medium' ); + if ( '' !== $url ) { + $images[] = array( + 'id' => $featured_id, + 'role' => 'featured', + 'url' => $url, + 'is_featured' => true, + ); + } + } + return $images; + } +} diff --git a/includes/admin/class-dossier-page.php b/includes/admin/class-dossier-page.php index f970fa2..2dedbc9 100644 --- a/includes/admin/class-dossier-page.php +++ b/includes/admin/class-dossier-page.php @@ -119,6 +119,42 @@ class PRAutoBlogger_Dossier_Page { PRAUTOBLOGGER_VERSION, true ); + + // M3: edit + re-run layer (separate files; dossier.css is not grown). + wp_enqueue_style( + 'prautoblogger-dossier-edit', + PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/dossier-edit.css', + array( 'prautoblogger-dossier' ), + PRAUTOBLOGGER_VERSION + ); + wp_enqueue_script( + 'prautoblogger-dossier-edit', + PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/dossier-edit.js', + array(), + PRAUTOBLOGGER_VERSION, + true + ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only context resolution for script config. + $post_id = isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : 0; + wp_localize_script( + 'prautoblogger-dossier-edit', + 'prabDossierEdit', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( PRAutoBlogger_Dossier_Actions::NONCE_ACTION ), + 'postId' => $post_id, + // Settings read at point of use -- same poll setting as the board. + 'pollInterval' => max( 3, (int) get_option( 'prautoblogger_board_poll_interval', 5 ) ), + 'strings' => array( + 'saving' => __( 'Saving…', 'prautoblogger' ), + 'error' => __( 'Request failed — try again.', 'prautoblogger' ), + 'queued' => __( 'Queued…', 'prautoblogger' ), + 'rerunFromLabel' => __( 'Re-run from here', 'prautoblogger' ), + 'confirmReplay' => __( 'Queue a re-run of this stage with your edited input? This makes a new LLM call billed against this run\'s ceiling, and all downstream stages will be marked stale (nothing re-runs automatically).', 'prautoblogger' ), + 'confirmRerunFrom' => __( 'Queue a re-run of this stage AND every stage after it? Each re-run stage makes new LLM calls billed against this run\'s ceiling. The article draft will be refreshed; published posts are never touched.', 'prautoblogger' ), + ), + ) + ); } /** diff --git a/includes/admin/class-dossier-rerun-panel-data.php b/includes/admin/class-dossier-rerun-panel-data.php new file mode 100644 index 0000000..92eb677 --- /dev/null +++ b/includes/admin/class-dossier-rerun-panel-data.php @@ -0,0 +1,181 @@ +|null $run Run ledger row, or null. + * @return array{settled: float, reserved: float, ceiling: float, fraction: float, warn: bool} + */ + public static function spend( ?array $run ): array { + $settled = null !== $run ? (float) ( $run['settled_usd'] ?? 0 ) : 0.0; + $reserved = null !== $run ? (float) ( $run['reserved_usd'] ?? 0 ) : 0.0; + $ceiling = null !== $run ? (float) ( $run['ceiling_usd'] ?? 0 ) : 0.0; + $fraction = $ceiling > 0 ? ( $settled + $reserved ) / $ceiling : 0.0; + + /** + * Filters the spend fraction at which re-run confirms warn about + * approaching the per-run ceiling. + * + * @param float $warn_fraction Fraction of ceiling (default 0.8). + */ + $warn_fraction = (float) apply_filters( 'prautoblogger_rerun_ceiling_warn_fraction', self::DEFAULT_WARN_FRACTION ); + + return array( + 'settled' => $settled, + 'reserved' => $reserved, + 'ceiling' => $ceiling, + 'fraction' => $fraction, + 'warn' => $ceiling > 0 && $fraction >= $warn_fraction, + ); + } + + /** + * Edit + re-run affordance data for one stage row. + * + * @param array $stage_row run_stages row. + * @param array> $log_rows generation_log rows for the stage. + * @param string $run_id Run UUID. + * @param array{ok: bool, reason: string} $eligibility Overall run eligibility. + * @return array Affordance view model (see keys below). + */ + public static function for_stage( array $stage_row, array $log_rows, string $run_id, array $eligibility ): array { + $stage = (string) ( $stage_row['stage'] ?? '' ); + $role = (string) ( $stage_row['agent_role'] ?? '' ); + $item_key = (string) ( $stage_row['item_key'] ?? '' ); + + $data = array( + 'stale' => ! empty( $stage_row['stale'] ), + 'human_modified' => ! empty( $stage_row['human_modified'] ), + 'attempt' => (int) ( $stage_row['attempt'] ?? 1 ), + 'editable' => false, + 'edit_reason' => '', + 'fork' => null, + 'prefill' => null, + 'rerun_from' => $eligibility['ok'] && PRAutoBlogger_Rerun_Eligibility::is_chain_stage( $stage ), + ); + + if ( ! PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ) { + $data['edit_reason'] = self::policy_reason( $stage ); + return $data; + } + if ( ! $eligibility['ok'] ) { + $data['edit_reason'] = $eligibility['reason']; + return $data; + } + + $fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $role, $item_key ); + if ( null !== $fork ) { + $data['fork'] = array( + 'version' => (int) $fork['version'], + 'author' => (string) $fork['author'], + 'created_at' => (string) $fork['created_at'], + 'has_body' => ! empty( $fork['request_json'] ), + ); + } + + $base_json = ( null !== $fork && ! empty( $fork['request_json'] ) ) + ? (string) $fork['request_json'] + : self::latest_request_json( $log_rows ); + + if ( null === $base_json ) { + $days = (int) get_option( 'prautoblogger_request_json_retention_days', PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS ); + $data['edit_reason'] = sprintf( + /* translators: %d: retention days. */ + __( 'No persisted input for this stage — request bodies are recorded from v0.20.0 onward and retained for %d days.', 'prautoblogger' ), + $days + ); + return $data; + } + + $body = PRAutoBlogger_Stage_Replay::decode_body( $base_json ); + if ( null === $body ) { + $data['edit_reason'] = __( 'The persisted input for this stage is not a replayable chat request.', 'prautoblogger' ); + return $data; + } + + $data['editable'] = true; + $data['prefill'] = array( + 'model' => (string) $body['model'], + 'messages' => $body['messages'], + ); + return $data; + } + + /** + * Why a non-writer stage has no edit affordance (always shown — + * the UI never silently hides the option). + * + * @param string $stage Stage name. + * @return string Operator-readable reason. + */ + private static function policy_reason( string $stage ): string { + if ( 'review' === $stage ) { + return __( 'The review input is derived entirely from the article content — edit a writing stage, then use "Re-run from here" on this one.', 'prautoblogger' ); + } + if ( 'publish' === $stage ) { + return __( 'Publish is not an LLM stage — use "Re-run from here" to refresh the post from current outputs.', 'prautoblogger' ); + } + if ( in_array( $stage, array( 'analysis', 'research', 'llm_research' ), true ) ) { + return __( 'Run-level stage: its output fed idea selection for the whole run and cannot be re-flowed into a single article.', 'prautoblogger' ); + } + if ( 0 === strpos( $stage, 'image' ) ) { + return __( 'Image stages are not replayable yet (Phase 2b restructure).', 'prautoblogger' ); + } + return __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ); + } + + /** + * The most recent recorded request body among a stage's log rows + * (success rows preferred, then latest id). + * + * @param array> $log_rows generation_log rows. + * @return string|null + */ + private static function latest_request_json( array $log_rows ): ?string { + $best = null; + foreach ( $log_rows as $row ) { + if ( empty( $row['request_json'] ) ) { + continue; + } + if ( null === $best ) { + $best = $row; + continue; + } + $row_success = ( 'success' === ( $row['response_status'] ?? '' ) ); + $best_success = ( 'success' === ( $best['response_status'] ?? '' ) ); + if ( ( $row_success && ! $best_success ) + || ( $row_success === $best_success && (int) ( $row['id'] ?? 0 ) > (int) ( $best['id'] ?? 0 ) ) ) { + $best = $row; + } + } + return null !== $best ? (string) $best['request_json'] : null; + } +} diff --git a/includes/class-pipeline-schema-installer.php b/includes/class-pipeline-schema-installer.php index aa76082..3ceee7c 100644 --- a/includes/class-pipeline-schema-installer.php +++ b/includes/class-pipeline-schema-installer.php @@ -4,12 +4,14 @@ declare(strict_types=1); /** * phpcs:ignore WordPress.Files.FileName.InvalidClassFileName -- class naming convention differs from WordPress standard * - * Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.2.0). + * Creates / updates the Pipeline v2 substrate tables via `dbDelta` (db 1.3.0). * - * What: Owns the five v0.18.0 substrate tables — the versioned prompt + * What: Owns the six pipeline substrate tables — the versioned prompt * registry (`prompts`), the audit child tables (`run_sources`, * `run_decisions`), and the run ledger / state machine tables - * (`runs`, `run_stages`). Kept separate from the original + * (`runs`, `run_stages`), and the immutable stage-input version + * store (`stage_inputs`, v0.20.0 / db 1.3.0 — edit + re-run forks + * and idea seeds). Kept separate from the original * PRAutoBlogger_Schema_Installer so both classes stay under the * 300-line cap and the v1.1.0 schema remains byte-stable. * Who triggers it: PRAutoBlogger_Activator::activate() — on activation and @@ -33,7 +35,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer { * plugin's forward-only schema policy. Safe to re-run on every * version mismatch. * - * Side effects: up to five CREATE TABLE / ALTER TABLE statements. + * Side effects: up to six CREATE TABLE / ALTER TABLE statements. * * @return void */ @@ -91,6 +93,7 @@ class PRAutoBlogger_Pipeline_Schema_Installer { verdict VARCHAR(50) NOT NULL, rationale TEXT DEFAULT NULL, citation_score FLOAT DEFAULT NULL, + human_modified TINYINT(1) NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY run_id (run_id) @@ -130,6 +133,8 @@ class PRAutoBlogger_Pipeline_Schema_Installer { attempt SMALLINT UNSIGNED NOT NULL DEFAULT 1, cost_usd DECIMAL(10,6) NOT NULL DEFAULT 0, meta_json LONGTEXT DEFAULT NULL, + human_modified TINYINT(1) NOT NULL DEFAULT 0, + stale TINYINT(1) NOT NULL DEFAULT 0, started_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, finished_at DATETIME DEFAULT NULL, @@ -138,10 +143,37 @@ class PRAutoBlogger_Pipeline_Schema_Installer { KEY status_updated (status, updated_at) ) {$charset_collate};"; + // Immutable stage-input version store (v0.20.0 / db 1.3.0, M3 + // edit + re-run). Rows are INSERT-only: a human edit creates the + // next (scope, version) row -- the original input (the + // generation_log.request_json of the executed call) is never + // overwritten (CPO guardrail 1). source='seed' rows persist the + // Article_Idea payload at worker start so re-run-from-here can + // reconstruct the exact idea (item_key hash stability); 'human' + // rows are edit forks. Human fork bodies are retention-pruned + // like all heavy payloads; seed rows (~1KB) are structural and + // kept for the life of the run. + $sql_stage_inputs = "CREATE TABLE {$prefix}stage_inputs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + run_id VARCHAR(36) NOT NULL, + stage VARCHAR(50) NOT NULL DEFAULT '', + agent_role VARCHAR(50) NOT NULL DEFAULT '', + item_key VARCHAR(64) NOT NULL DEFAULT '', + version INT UNSIGNED NOT NULL DEFAULT 1, + source VARCHAR(20) NOT NULL DEFAULT 'human', + request_json LONGTEXT DEFAULT NULL, + author VARCHAR(100) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY scope_version (run_id, stage, agent_role, item_key, version), + KEY run_id (run_id) + ) {$charset_collate};"; + dbDelta( $sql_prompts ); dbDelta( $sql_run_sources ); dbDelta( $sql_run_decisions ); dbDelta( $sql_runs ); dbDelta( $sql_run_stages ); + dbDelta( $sql_stage_inputs ); } } diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index fcd2d43..9ebf6a9 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -141,6 +141,11 @@ class PRAutoBlogger { new PRAutoBlogger_Opik_Settings(); } + // v0.20.0 (M3): operator re-run jobs — chained-cron, never synchronous. + $rerun_executor = new PRAutoBlogger_Rerun_Executor(); + add_action( PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, array( $rerun_executor, 'on_replay_job' ), 10, 5 ); + add_action( PRAutoBlogger_Rerun_Executor::REBUILD_ACTION, array( $rerun_executor, 'on_rebuild_job' ), 10, 4 ); + add_action( 'prautoblogger_generate_from_idea', array( 'PRAutoBlogger_Ideas_Browser', 'on_cron_generate_from_idea' ) ); add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Research_Reaper', 'on_cron' ) ); add_action( 'prautoblogger_reap_orphan_research_rows', array( 'PRAutoBlogger_Run_Reaper', 'on_cron' ) ); @@ -174,6 +179,13 @@ class PRAutoBlogger { add_action( 'wp_ajax_prautoblogger_clear_logs', array( new PRAutoBlogger_Log_Viewer(), 'on_ajax_clear_logs' ) ); add_action( 'wp_ajax_' . PRAutoBlogger_Board_Page::AJAX_ACTION, array( new PRAutoBlogger_Board_Page(), 'on_ajax_board_status' ) ); + // v0.20.0 (M3): dossier edit + re-run endpoints (validate + queue only). + $dossier_actions = new PRAutoBlogger_Dossier_Actions(); + add_action( 'wp_ajax_prautoblogger_dossier_save_input', array( $dossier_actions, 'on_save_input' ) ); + add_action( 'wp_ajax_prautoblogger_dossier_rerun_stage', array( $dossier_actions, 'on_rerun_stage' ) ); + add_action( 'wp_ajax_prautoblogger_dossier_rerun_from', array( $dossier_actions, 'on_rerun_from' ) ); + add_action( 'wp_ajax_prautoblogger_dossier_stage_status', array( $dossier_actions, 'on_stage_status' ) ); + $ideas = new PRAutoBlogger_Ideas_Browser(); add_action( 'wp_ajax_prautoblogger_generate_from_idea', array( $ideas, 'on_ajax_generate_from_idea' ) ); add_action( 'wp_ajax_prautoblogger_idea_gen_status', array( $ideas, 'on_ajax_idea_gen_status' ) ); diff --git a/includes/core/class-article-worker.php b/includes/core/class-article-worker.php index ab56994..d65426d 100644 --- a/includes/core/class-article-worker.php +++ b/includes/core/class-article-worker.php @@ -58,6 +58,13 @@ class PRAutoBlogger_Article_Worker { $run_id = (string) ( $this->cost_tracker->get_run_id() ?? '' ); $item = PRAutoBlogger_Run_Stage_State::item_key_for_idea( $idea ); + // v0.20.0: persist the EXACT idea payload once per item so + // re-run-from-here can reconstruct it (rebuilding from post + // fields would risk an item_key hash mismatch -> duplicate post). + if ( '' !== $run_id ) { + PRAutoBlogger_Stage_Input_Store::save_seed( $run_id, $item, (string) wp_json_encode( $idea->to_array() ) ); + } + $result = array( 'generated' => 0, 'published' => 0, diff --git a/includes/core/class-audit-writer.php b/includes/core/class-audit-writer.php index 55537b5..9ad7d46 100644 --- a/includes/core/class-audit-writer.php +++ b/includes/core/class-audit-writer.php @@ -125,6 +125,37 @@ class PRAutoBlogger_Audit_Writer { } } + /** + * Flag the run_decisions rows of given stages as human-modified + * (v0.20.0 / CPO guardrail 2). Used in two cases: the decision rows of + * a stage whose edited input enters execution, and decisions recorded + * during a re-run-from-here window on an item that carries a human + * edit (derived-from-edited-content). Self-healing no-op when the + * column/table is missing. + * + * @param string $run_id Run UUID. + * @param array $stages Stage names to flag. + * @param string|null $since Optional MySQL datetime — only flag rows created at/after it. + * @return int Rows flagged. + */ + public static function flag_decisions_human_modified( string $run_id, array $stages, ?string $since = null ): int { + if ( '' === $run_id || empty( $stages ) || ! self::is_available() ) { + return 0; + } + global $wpdb; + $table = $wpdb->prefix . 'prautoblogger_run_decisions'; + $placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) ); + $sql = "UPDATE {$table} SET human_modified = 1 WHERE run_id = %s AND stage IN ({$placeholders})"; + $params = array_merge( array( $run_id ), array_values( $stages ) ); + if ( null !== $since ) { + $sql .= ' AND created_at >= %s'; + $params[] = $since; + } + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list. + $updated = $wpdb->query( $wpdb->prepare( $sql, $params ) ); + return is_numeric( $updated ) ? (int) $updated : 0; + } + /** Reset per-request caches (tests). */ public static function flush_cache(): void { self::$tables_ok = null; diff --git a/includes/core/class-cost-tracker.php b/includes/core/class-cost-tracker.php index cd970eb..1898608 100644 --- a/includes/core/class-cost-tracker.php +++ b/includes/core/class-cost-tracker.php @@ -69,7 +69,9 @@ class PRAutoBlogger_Cost_Tracker { /** * Log an API call with its token usage and estimated cost. * - * Side effects: database insert into prab_generation_log. + * Side effects: database insert into prab_generation_log. Consumes the + * Request_Recorder stash (v0.20.0 / B1) into the row's request_json so + * every chat call's input is persisted (retention-pruned after R days). * * @param int|null $post_id WordPress post ID (null during generation, set later). * @param string $stage Pipeline stage (see PRAutoBlogger_Stage_Display_Map). @@ -117,6 +119,7 @@ class PRAutoBlogger_Cost_Tracker { 'estimated_cost' => $cost, 'response_status' => $response_status, 'error_message' => $error_message, + 'request_json' => PRAutoBlogger_Request_Recorder::consume(), 'agent_role' => $agent_role ?? PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ), 'prompt_version' => $this->resolve_prompt_version( $stage, $prompt_key ), 'created_at' => current_time( 'mysql' ), diff --git a/includes/core/class-publisher.php b/includes/core/class-publisher.php index 696c3d5..598a141 100644 --- a/includes/core/class-publisher.php +++ b/includes/core/class-publisher.php @@ -107,11 +107,11 @@ class PRAutoBlogger_Publisher { if ( null !== $run_id && '' !== $run_id ) { $existing_id = $this->find_existing_post( $run_id, $idea_hash ); if ( $existing_id > 0 ) { - PRAutoBlogger_Logger::instance()->info( - sprintf( 'Post for run %s / idea %s already exists (ID=%d) — skipping duplicate insert.', $run_id, $idea_hash, $existing_id ), - 'publisher' - ); - return $existing_id; + // v0.20.0: a re-run's regenerated content must land on the + // existing UNPUBLISHED post (otherwise re-runs silently + // discard their output). Published posts stay frozen + // (CPO guardrail 5) — refresh skips them untouched. + return $this->refresh_unpublished_post( $existing_id, $content, $idea, $review ); } } @@ -157,6 +157,60 @@ class PRAutoBlogger_Publisher { return $post_id; } + /** + * Refresh an existing post from re-run output — unpublished only. + * + * Idempotent-resume calls land here too (same content in = same + * content out, so behavior is unchanged for plain resumes). The post + * keeps its CURRENT status: a re-run never publishes an unpublished + * post — publication stays an explicit Review Queue / WP editor + * action. Published (and scheduled/private) posts are returned + * untouched: the run is frozen (CPO guardrail 5). + * + * Side effects: wp_update_post + post meta updates (unpublished only). + * + * @param int $post_id Existing post ID. + * @param string $content Regenerated HTML content. + * @param PRAutoBlogger_Article_Idea $idea The idea. + * @param PRAutoBlogger_Editorial_Review $review The fresh review. + * @return int The post ID. + */ + private function refresh_unpublished_post( int $post_id, string $content, PRAutoBlogger_Article_Idea $idea, PRAutoBlogger_Editorial_Review $review ): int { + $post = get_post( $post_id ); + if ( PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ) ) { + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Post %d already exists and is published — frozen, skipping content refresh.', $post_id ), + 'publisher' + ); + return $post_id; + } + + // Same sanitize chain as create_post(). + $clean_content = PRAutoBlogger_Post_Assembler::sanitize_llm_content( $content ); + $linked_content = PRAutoBlogger_Peptide_Linker::inject_links( $clean_content ); + + wp_update_post( + array( + 'ID' => $post_id, + 'post_title' => sanitize_text_field( $idea->get_suggested_title() ), + 'post_content' => wp_kses_post( $linked_content ), + ) + ); + + update_post_meta( $post_id, '_prautoblogger_editor_verdict', $review->get_verdict() ); + update_post_meta( $post_id, '_prautoblogger_editor_notes', $review->get_notes() ); + update_post_meta( $post_id, '_prautoblogger_quality_score', $review->get_quality_score() ); + update_post_meta( $post_id, '_prautoblogger_seo_score', $review->get_seo_score() ); + update_post_meta( $post_id, '_prautoblogger_generated_at', gmdate( 'c' ) ); + + PRAutoBlogger_Logger::instance()->info( + sprintf( 'Post %d refreshed from re-run output (status preserved: %s).', $post_id, (string) ( $post->post_status ?? '' ) ), + 'publisher' + ); + + return $post_id; + } + /** * Build post_meta array for generation metadata. * diff --git a/includes/core/class-request-recorder.php b/includes/core/class-request-recorder.php new file mode 100644 index 0000000..777b40c --- /dev/null +++ b/includes/core/class-request-recorder.php @@ -0,0 +1,73 @@ + $body Request body from Request_Builder::build_body(). + * @return void + */ + public static function record( array $body ): void { + $encoded = wp_json_encode( $body ); + self::$request_json = is_string( $encoded ) ? $encoded : null; + } + + /** + * Return the stashed request body and clear the slot (consume-once). + * + * @return string|null JSON request body, or null when nothing is stashed. + */ + public static function consume(): ?string { + $json = self::$request_json; + self::$request_json = null; + return $json; + } + + /** + * Clear the stash (tests / defensive teardown). + * + * @return void + */ + public static function clear(): void { + self::$request_json = null; + } +} diff --git a/includes/core/class-rerun-eligibility.php b/includes/core/class-rerun-eligibility.php new file mode 100644 index 0000000..6ac77ca --- /dev/null +++ b/includes/core/class-rerun-eligibility.php @@ -0,0 +1,214 @@ + Downstream stage names ([] for non-chain stages). + */ + public static function downstream_of( string $stage ): array { + $idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true ); + if ( false === $idx ) { + return array(); + } + return array_slice( self::ITEM_STAGE_CHAIN, $idx + 1 ); + } + + /** + * The demotion set for re-run-from-here: the stage itself plus all + * downstream chain stages. + * + * @param string $stage Stage name. + * @return array + */ + public static function rebuild_set( string $stage ): array { + $idx = array_search( $stage, self::ITEM_STAGE_CHAIN, true ); + if ( false === $idx ) { + return array(); + } + return array_slice( self::ITEM_STAGE_CHAIN, $idx ); + } + + /** + * Whether a post is frozen for pipeline re-runs (guardrail 5). + * + * @param WP_Post|null $post The post, or null when none exists yet. + * @return bool + */ + public static function post_frozen( ?WP_Post $post ): bool { + return ( $post instanceof WP_Post ) + && in_array( (string) $post->post_status, self::FROZEN_STATUSES, true ); + } + + /** + * Full re-run eligibility check for a (run, post) pair. + * + * Returns ok=false with an operator-readable reason for: missing/ + * frozen post, missing run row, run actively executing, or the + * global generation lock being held. Called from the AJAX layer for + * UI affordances AND re-validated by the cron handler under the lock + * (state can change between click and pickup). + * + * @param string $run_id Run UUID. + * @param int $post_id Post ID (0 = run has no post yet). + * @param bool $require_idle_lock Whether a held generation lock blocks (true for actions). + * @return array{ok: bool, reason: string} Verdict + reason ('' when ok). + */ + public static function check( string $run_id, int $post_id, bool $require_idle_lock = true ): array { + if ( '' === $run_id ) { + return self::no( __( 'This article has no recorded generation run.', 'prautoblogger' ) ); + } + + $post = $post_id > 0 ? get_post( $post_id ) : null; + if ( self::post_frozen( $post ) ) { + return self::no( __( 'This article is published — its run is frozen. Edit the article in the WordPress editor instead.', 'prautoblogger' ) ); + } + + $run = PRAutoBlogger_Run_State::get_run( $run_id ); + if ( null === $run ) { + return self::no( __( 'No run ledger row exists for this article (pre-substrate run).', 'prautoblogger' ) ); + } + if ( in_array( (string) $run['status'], array( 'pending', 'running' ), true ) ) { + return self::no( __( 'This run is currently executing. Wait for it to finish (or for the reaper to mark it failed).', 'prautoblogger' ) ); + } + + if ( $require_idle_lock && null !== PRAutoBlogger_Generation_Lock::get_acquired_at() ) { + return self::no( __( 'Another generation is in progress (lock held). Try again when it completes.', 'prautoblogger' ) ); + } + + return array( + 'ok' => true, + 'reason' => '', + ); + } + + /** + * Replay-specific check: base eligibility + editable stage + a saved + * input fork whose body has not been retention-pruned. + * + * @param string $run_id Run UUID. + * @param int $post_id Post ID. + * @param string $stage Stage name. + * @param string $agent_role Stage row agent role. + * @param string $item_key Stage row item key. + * @param bool $require_idle_lock False when the caller already owns the lock (cron handler). + * @return array{ok: bool, reason: string} + */ + public static function check_replay( string $run_id, int $post_id, string $stage, string $agent_role, string $item_key, bool $require_idle_lock = true ): array { + if ( ! self::is_editable_stage( $stage ) ) { + return self::no( __( 'This stage cannot be replayed with an edited input.', 'prautoblogger' ) ); + } + $base = self::check( $run_id, $post_id, $require_idle_lock ); + if ( ! $base['ok'] ) { + return $base; + } + $fork = PRAutoBlogger_Stage_Input_Store::latest_fork( $run_id, $stage, $agent_role, $item_key ); + if ( null === $fork ) { + return self::no( __( 'Save an edited input for this stage first — re-run executes your latest saved version.', 'prautoblogger' ) ); + } + if ( empty( $fork['request_json'] ) ) { + return self::no( __( 'The saved input for this stage was pruned by the retention cron and can no longer be replayed.', 'prautoblogger' ) ); + } + return $base; + } + + /** + * Rebuild-specific check: base eligibility + chain stage + idea seed + * available (runs started before v0.20.0 have no seed). + * + * @param string $run_id Run UUID. + * @param int $post_id Post ID. + * @param string $stage Target stage. + * @param string $item_key Item key. + * @param bool $require_idle_lock False when the caller already owns the lock (cron handler). + * @return array{ok: bool, reason: string} + */ + public static function check_rebuild( string $run_id, int $post_id, string $stage, string $item_key, bool $require_idle_lock = true ): array { + if ( ! self::is_chain_stage( $stage ) ) { + return self::no( __( 'Re-run from here is only available on article pipeline stages.', 'prautoblogger' ) ); + } + $base = self::check( $run_id, $post_id, $require_idle_lock ); + if ( ! $base['ok'] ) { + return $base; + } + if ( null === PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key ) ) { + return self::no( __( 'No idea seed is stored for this run (recorded from v0.20.0 onward) — the pipeline cannot rebuild it safely.', 'prautoblogger' ) ); + } + return $base; + } + + /** + * Shorthand for a negative verdict. + * + * @param string $reason Operator-readable reason. + * @return array{ok: bool, reason: string} + */ + private static function no( string $reason ): array { + return array( + 'ok' => false, + 'reason' => $reason, + ); + } +} diff --git a/includes/core/class-rerun-executor.php b/includes/core/class-rerun-executor.php new file mode 100644 index 0000000..3ad8672 --- /dev/null +++ b/includes/core/class-rerun-executor.php @@ -0,0 +1,221 @@ +getMessage() ); + } catch ( \Throwable $e ) { + PRAutoBlogger_Run_Stage_State::fail( $run_id, $stage, $agent_role, $item_key ); + PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' ); + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Stage replay %s for run %s/%s: %s', get_class( $e ), $run_id, $stage, $e->getMessage() ), + 'rerun' + ); + PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() ); + } finally { + PRAutoBlogger_Generation_Lock::release(); + } + } + + /** + * Cron handler: re-run from a stage — demote it + downstream, then + * re-enter the worker so demoted stages rebuild from current + * upstream snapshots. + * + * Side effects: run_stages/runs mutations, worker LLM calls + * (governed), post refresh, status transient, lock acquire/release. + * + * @param string $run_id Run UUID. + * @param int $post_id Post the dossier acted on. + * @param string $stage Target stage (start of the rebuild set). + * @param string $item_key Item key. + * @return void + */ + public function on_rebuild_job( string $run_id, int $post_id, string $stage, string $item_key ): void { + PRAutoBlogger_Rerun_Job_Support::harden_process(); + if ( ! PRAutoBlogger_Rerun_Job_Support::acquire_lock_or_report() ) { + return; + } + + try { + // require_idle_lock=false: THIS handler owns the lock. + $check = PRAutoBlogger_Rerun_Eligibility::check_rebuild( $run_id, $post_id, $stage, $item_key, false ); + if ( ! $check['ok'] ) { + PRAutoBlogger_Rerun_Job_Support::finish_error( $check['reason'] ); + return; + } + if ( PRAutoBlogger_Rerun_Job_Support::monthly_budget_exceeded() ) { + PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'Monthly API budget exceeded. Re-run not started.', 'prautoblogger' ) ); + return; + } + + $idea_json = PRAutoBlogger_Stage_Input_Store::get_seed( $run_id, $item_key ); + $idea_data = null !== $idea_json ? json_decode( $idea_json, true ) : null; + if ( ! is_array( $idea_data ) ) { + PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The stored idea seed could not be decoded.', 'prautoblogger' ) ); + return; + } + $idea = new PRAutoBlogger_Article_Idea( $idea_data ); + + $job_start = current_time( 'mysql' ); + $demoted = PRAutoBlogger_Rerun_Eligibility::rebuild_set( $stage ); + + if ( ! PRAutoBlogger_Run_State::reopen( $run_id ) ) { + PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The run could not be reopened for new spend.', 'prautoblogger' ) ); + return; + } + PRAutoBlogger_Run_Stage_Rerun_State::demote_to_pending( $run_id, $item_key, $demoted ); + + PRAutoBlogger_Rerun_Job_Support::broadcast( + sprintf( + /* translators: %s: stage display label. */ + __( 'Re-running the pipeline from %s…', 'prautoblogger' ), + PRAutoBlogger_Stage_Display_Map::label( $stage ) + ) + ); + + $tracker = new PRAutoBlogger_Cost_Tracker(); + $tracker->set_run_id( $run_id ); + $result = ( new PRAutoBlogger_Article_Worker( $tracker ) )->generate( $idea ); + + // The worker traps its own failures (fail_open_for_item); + // publish-done is the success signal. + $succeeded = PRAutoBlogger_Run_Stage_State::is_done( $run_id, 'publish', '', $item_key ); + + // Decisions recorded while rebuilding a human-modified item + // derive from edited content — flag them (guardrail 2). + if ( PRAutoBlogger_Run_Stage_Rerun_State::item_has_human_modified( $run_id, $item_key ) ) { + PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, $demoted, $job_start ); + } + + PRAutoBlogger_Run_State::mark_status( $run_id, $succeeded ? 'done' : 'failed' ); + if ( $succeeded ) { + PRAutoBlogger_Rerun_Job_Support::finish_complete( (float) $result['cost'] ); + } else { + PRAutoBlogger_Rerun_Job_Support::finish_error( __( 'The re-run did not complete — check the Activity Log and the dossier stage states.', 'prautoblogger' ) ); + } + } catch ( \Throwable $e ) { + PRAutoBlogger_Run_State::mark_status( $run_id, 'failed' ); + PRAutoBlogger_Logger::instance()->error( + sprintf( 'Rebuild from %s %s for run %s: %s', $stage, get_class( $e ), $run_id, $e->getMessage() ), + 'rerun' + ); + PRAutoBlogger_Rerun_Job_Support::finish_error( $e->getMessage() ); + } finally { + PRAutoBlogger_Generation_Lock::release(); + } + } +} diff --git a/includes/core/class-rerun-job-support.php b/includes/core/class-rerun-job-support.php new file mode 100644 index 0000000..69ddc9a --- /dev/null +++ b/includes/core/class-rerun-job-support.php @@ -0,0 +1,158 @@ + 0.01, + 'blocking' => false, + 'sslverify' => false, + ) + ); + } + + /** Raise process limits for a background LLM job (cron context). */ + public static function harden_process(): void { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @ignore_user_abort( true ); + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @set_time_limit( 300 ); + } + + /** + * Acquire the global generation mutex or report the conflict. + * + * @return bool Whether the lock was acquired. + */ + public static function acquire_lock_or_report(): bool { + if ( PRAutoBlogger_Generation_Lock::acquire() ) { + return true; + } + self::finish_error( __( 'Could not acquire the generation lock — another run is in progress. Re-run again when it completes.', 'prautoblogger' ) ); + return false; + } + + /** + * Whether the monthly budget hard-stop blocks new spend. + * + * @return bool + */ + public static function monthly_budget_exceeded(): bool { + return ( new PRAutoBlogger_Cost_Tracker() )->is_budget_exceeded(); + } + + /** + * Restore a terminal run status after an aborted-but-reopened job. + * + * @param string $run_id Run UUID. + * @param string|null $status The pre-reopen status (done/failed/halted). + * @return void + */ + public static function restore_terminal( string $run_id, ?string $status ): void { + if ( null !== $status && in_array( $status, array( 'done', 'failed', 'halted' ), true ) ) { + PRAutoBlogger_Run_State::mark_status( $run_id, $status ); + } + } + + /** + * Update the live status transient (board + dossier polling). + * + * @param string $message Stage message. + * @return void + */ + public static function broadcast( string $message ): void { + set_transient( + PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, + array( + 'status' => 'running', + 'stage' => $message, + 'started' => time(), + 'last_updated' => time(), + ), + PRAutoBlogger_Generation_Status_Poller::STATUS_TTL + ); + } + + /** + * Final transient: success (board-compatible shape). + * + * @param float $cost Job spend in USD. + * @return void + */ + public static function finish_complete( float $cost ): void { + set_transient( + PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, + array( + 'status' => 'complete', + 'generated' => 0, + 'published' => 0, + 'rejected' => 0, + 'cost' => $cost, + 'message' => __( 'Re-run complete.', 'prautoblogger' ), + ), + PRAutoBlogger_Generation_Status_Poller::STATUS_TTL + ); + } + + /** + * Final transient: error (board-compatible shape) + log. + * + * @param string $message Operator-facing error. + * @return void + */ + public static function finish_error( string $message ): void { + PRAutoBlogger_Logger::instance()->warning( 'Re-run job: ' . $message, 'rerun' ); + set_transient( + PRAutoBlogger_Generation_Status_Poller::STATUS_TRANSIENT, + array( + 'status' => 'error', + 'message' => $message, + ), + PRAutoBlogger_Generation_Status_Poller::STATUS_TTL + ); + } +} diff --git a/includes/core/class-run-reaper.php b/includes/core/class-run-reaper.php index 6f1c86e..da85734 100644 --- a/includes/core/class-run-reaper.php +++ b/includes/core/class-run-reaper.php @@ -16,8 +16,9 @@ declare(strict_types=1); * ledger/state activity for 2× the expected run wall-clock are * marked `failed` and their non-done stages reaped to `failed`; * such runs render as "incomplete" via incomplete_runs(). - * 3. Retention — `request_json` on generation_log and stage output + * 3. Retention — `request_json` on generation_log, stage output * payloads (`meta_json` on run_stages) are NULLed after R days; + * payloads, and stage_inputs human-fork bodies (v0.20.0); * R comes from the `prautoblogger_request_json_retention_days` * SETTING (default constant 14; 0 = keep forever) — never a * literal at the point of use. @@ -265,6 +266,11 @@ class PRAutoBlogger_Run_Reaper { $pruned += is_numeric( $result ) ? (int) $result : 0; } + // v0.20.0: human edit-fork bodies ride the same retention (seed + // rows are kept — structural, ~1KB). A pruned fork can no longer + // be replayed; the dossier explains that state honestly. + $pruned += PRAutoBlogger_Stage_Input_Store::prune_human_bodies( $cutoff ); + return $pruned; } } diff --git a/includes/core/class-run-stage-rerun-state.php b/includes/core/class-run-stage-rerun-state.php new file mode 100644 index 0000000..7471e76 --- /dev/null +++ b/includes/core/class-run-stage-rerun-state.php @@ -0,0 +1,172 @@ +query( + $wpdb->prepare( + "UPDATE {$table} + SET status = 'running', attempt = attempt + 1, stale = 0, + human_modified = IF(%d = 1, 1, human_modified), + updated_at = %s, finished_at = NULL + WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s + AND status IN ('done','failed','halted','pending')", + $human_modified ? 1 : 0, + $now, + $run_id, + $stage, + $agent_role, + $item_key + ) + ); + return 1 === (int) $updated; + } + + /** + * Flag a set of 'done' stages as stale (explicit downstream + * invalidation after an upstream edit/re-run — CPO guardrail 3). + * + * Nothing is demoted and nothing auto-re-runs: the rows stay 'done' + * and visible; the operator triggers each subsequent stage (or + * re-run-from-here) deliberately. + * + * @param string $run_id Run UUID. + * @param string $item_key Item key. + * @param array $stages Stage names to flag. + * @return int Rows flagged. + */ + public static function mark_stale( string $run_id, string $item_key, array $stages ): int { + if ( '' === $run_id || empty( $stages ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) { + return 0; + } + global $wpdb; + $table = PRAutoBlogger_Run_Stage_State::table_name(); + $placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) ); + $params = array_merge( array( current_time( 'mysql' ), $run_id, $item_key ), array_values( $stages ) ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list. + $updated = $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} SET stale = 1, updated_at = %s + WHERE run_id = %s AND item_key = %s AND status = 'done' AND stage IN ({$placeholders})", + $params + ) + ); + return is_numeric( $updated ) ? (int) $updated : 0; + } + + /** + * Demote a set of stages to 'pending' for re-run-from-here. + * + * The pipeline's resume machinery treats pending stages as not-done + * and re-executes them with prompts REBUILT from current upstream + * snapshots — that is how an upstream edit propagates downstream. + * Rows keep attempt/human_modified (audit) and their old meta_json + * until the fresh completion overwrites it; `stale` stays 1 until + * Run_Stage_Writes::done() clears it on fresh output. Never touches + * a 'running' row. + * + * @param string $run_id Run UUID. + * @param string $item_key Item key. + * @param array $stages Stage names to demote. + * @return int Rows demoted. + */ + public static function demote_to_pending( string $run_id, string $item_key, array $stages ): int { + if ( '' === $run_id || empty( $stages ) || ! PRAutoBlogger_Run_Stage_State::is_available() ) { + return 0; + } + global $wpdb; + $table = PRAutoBlogger_Run_Stage_State::table_name(); + $placeholders = implode( ',', array_fill( 0, count( $stages ), '%s' ) ); + $params = array_merge( array( current_time( 'mysql' ), $run_id, $item_key ), array_values( $stages ) ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list. + $updated = $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} SET status = 'pending', stale = 1, updated_at = %s, finished_at = NULL + WHERE run_id = %s AND item_key = %s AND status != 'running' AND stage IN ({$placeholders})", + $params + ) + ); + return is_numeric( $updated ) ? (int) $updated : 0; + } + /** + * Whether any stage row of this item carries the human_modified flag + * (drives derived-decision flagging during rebuilds — guardrail 2). + * + * @param string $run_id Run UUID. + * @param string $item_key Item key. + * @return bool + */ + public static function item_has_human_modified( string $run_id, string $item_key ): bool { + if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) { + return false; + } + global $wpdb; + $table = PRAutoBlogger_Run_Stage_State::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $found = $wpdb->get_var( + $wpdb->prepare( + "SELECT 1 FROM {$table} WHERE run_id = %s AND item_key = %s AND human_modified = 1 LIMIT 1", + $run_id, + $item_key + ) + ); + return null !== $found; + } +} diff --git a/includes/core/class-run-stage-state.php b/includes/core/class-run-stage-state.php index 548ca71..2826073 100644 --- a/includes/core/class-run-stage-state.php +++ b/includes/core/class-run-stage-state.php @@ -16,9 +16,17 @@ declare(strict_types=1); * 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. * + * v0.20.0: write bodies extracted to PRAutoBlogger_Run_Stage_Writes (the + * M2 split pattern — this class was at the 300-line cap); the public API + * here is unchanged and delegates. Operator-action mutations (replay + * restart, stale marking, re-run demotion) live in + * PRAutoBlogger_Run_Stage_Rerun_State only. + * + * @see core/class-run-stage-writes.php — Extracted write bodies. + * @see core/class-run-stage-rerun-state.php — M3 operator-action writes. * @see core/class-run-state.php — Run-level lifecycle + ledger row. * @see core/class-run-reaper.php — Sweeps stages stuck in 'running'. - * @see ARCHITECTURE.md #22 — Pipeline v2 Phase 1 substrate. + * @see ARCHITECTURE.md #22 / #24 — Substrate + edit/re-run design. */ class PRAutoBlogger_Run_Stage_State { @@ -73,6 +81,7 @@ class PRAutoBlogger_Run_Stage_State { /** * Mark a stage as running. Sticky: done stages are never demoted. + * Delegates to Run_Stage_Writes (v0.20.0 split — behavior unchanged). * * @param string $run_id Run UUID. * @param string $stage Stage name (see Stage_Display_Map). @@ -80,35 +89,13 @@ class PRAutoBlogger_Run_Stage_State { * @param string $item_key Article scope ('' for run-level stages). */ public static function start( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void { - if ( '' === $run_id || ! self::is_available() ) { - return; - } - $agent_role = self::resolve_role( $stage, $agent_role ); - global $wpdb; - $table = self::table_name(); - $now = current_time( 'mysql' ); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $wpdb->query( - $wpdb->prepare( - "INSERT INTO {$table} - (run_id, stage, agent_role, item_key, status, attempt, started_at, updated_at) - VALUES (%s, %s, %s, %s, 'running', 1, %s, %s) - ON DUPLICATE KEY UPDATE - attempt = IF(status = 'done', attempt, attempt + 1), - status = IF(status = 'done', 'done', 'running'), - updated_at = VALUES(updated_at)", - $run_id, - $stage, - $agent_role, - $item_key, - $now, - $now - ) - ); + PRAutoBlogger_Run_Stage_Writes::start( $run_id, $stage, $agent_role, $item_key ); } /** * Mark a stage done and persist its output for resume-without-recharge. + * Delegates to Run_Stage_Writes (v0.20.0 split; fresh completion also + * clears the `stale` flag there). * * @param string $run_id Run UUID. * @param string $stage Stage name. @@ -118,41 +105,12 @@ class PRAutoBlogger_Run_Stage_State { * @param float $cost_usd Cost attributed to this stage. */ public static function done( string $run_id, string $stage, string $agent_role = '', string $item_key = '', ?string $output = null, float $cost_usd = 0.0 ): void { - if ( '' === $run_id || ! self::is_available() ) { - return; - } - $agent_role = self::resolve_role( $stage, $agent_role ); - global $wpdb; - $table = self::table_name(); - $now = current_time( 'mysql' ); - $meta = null !== $output ? wp_json_encode( array( 'output' => $output ) ) : null; - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $wpdb->query( - $wpdb->prepare( - "INSERT INTO {$table} - (run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, started_at, updated_at, finished_at) - VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, %s, %s, %s) - ON DUPLICATE KEY UPDATE - status = 'done', - cost_usd = VALUES(cost_usd), - meta_json = VALUES(meta_json), - updated_at = VALUES(updated_at), - finished_at = VALUES(finished_at)", - $run_id, - $stage, - $agent_role, - $item_key, - $cost_usd, - $meta, - $now, - $now, - $now - ) - ); + PRAutoBlogger_Run_Stage_Writes::done( $run_id, $stage, $agent_role, $item_key, $output, $cost_usd ); } /** - * Mark a stage failed (does not demote a done stage). + * Mark a stage failed (does not demote a done stage). Delegates to + * Run_Stage_Writes (v0.20.0 split — behavior unchanged). * * @param string $run_id Run UUID. * @param string $stage Stage name. @@ -160,26 +118,7 @@ class PRAutoBlogger_Run_Stage_State { * @param string $item_key Article scope. */ public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void { - if ( '' === $run_id || ! self::is_available() ) { - return; - } - $agent_role = self::resolve_role( $stage, $agent_role ); - global $wpdb; - $table = self::table_name(); - $now = current_time( 'mysql' ); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $wpdb->query( - $wpdb->prepare( - "UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s - WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'", - $now, - $now, - $run_id, - $stage, - $agent_role, - $item_key - ) - ); + PRAutoBlogger_Run_Stage_Writes::fail( $run_id, $stage, $agent_role, $item_key ); } /** @@ -196,7 +135,7 @@ class PRAutoBlogger_Run_Stage_State { if ( '' === $run_id || ! self::is_available() ) { return null; } - $agent_role = self::resolve_role( $stage, $agent_role ); + $agent_role = PRAutoBlogger_Run_Stage_Writes::resolve_role( $stage, $agent_role ); global $wpdb; $table = self::table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching @@ -265,49 +204,68 @@ class PRAutoBlogger_Run_Stage_State { /** * Fail every still-open stage of one item. Done stages are untouched. + * Delegates to Run_Stage_Writes (v0.20.0 split — behavior unchanged). * * @param string $run_id Run UUID. * @param string $item_key Stage item key ('' = run-level stages). */ public static function fail_open_for_item( string $run_id, string $item_key ): void { + PRAutoBlogger_Run_Stage_Writes::fail_open_for_item( $run_id, $item_key ); + } + + /** + * All stage rows for one item of a run (plus run-level rows when + * $include_run_level), ordered by id. Read API for the rerun engine + * and the dossier's item-scoped assembly (v0.20.0). + * + * @param string $run_id Run UUID. + * @param string $item_key Item key ('idea:'). + * @param bool $include_run_level Include item_key='' rows too. + * @return array> Stage rows (ARRAY_A). + */ + public static function stages_for_item( string $run_id, string $item_key, bool $include_run_level = true ): array { if ( '' === $run_id || ! self::is_available() ) { - return; + return array(); } global $wpdb; $table = self::table_name(); - $now = current_time( 'mysql' ); - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery - $wpdb->query( + $sql = $include_run_level + ? "SELECT * FROM {$table} WHERE run_id = %s AND item_key IN (%s, '') ORDER BY id ASC" + : "SELECT * FROM {$table} WHERE run_id = %s AND item_key = %s ORDER BY id ASC"; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- assembled from two fixed literals above. + $rows = $wpdb->get_results( $wpdb->prepare( $sql, $run_id, $item_key ), ARRAY_A ); + return is_array( $rows ) ? $rows : array(); + } + + /** + * Which of the given runs carry at least one stale stage (batched -- + * feeds the Review Queue warning chips without N+1; CPO product AC: + * a stale flag is present at publish). Self-healing: missing table + * or column returns []. + * + * @param array $run_ids Run UUIDs. + * @return array Subset of $run_ids having stale stages. + */ + public static function runs_with_stale_stages( array $run_ids ): array { + $run_ids = array_values( array_filter( array_map( 'strval', $run_ids ) ) ); + if ( empty( $run_ids ) || ! self::is_available() ) { + return array(); + } + global $wpdb; + $table = self::table_name(); + $placeholders = implode( ',', array_fill( 0, count( $run_ids ), '%s' ) ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- $placeholders is a fixed %s list. + $rows = $wpdb->get_col( $wpdb->prepare( - "UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s - WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')", - $now, - $now, - $run_id, - $item_key + "SELECT DISTINCT run_id FROM {$table} WHERE stale = 1 AND run_id IN ({$placeholders})", + $run_ids ) ); + return is_array( $rows ) ? array_map( 'strval', $rows ) : array(); } /** Reset per-request caches (tests). */ public static function flush_cache(): void { self::$table_ok = null; } - - /** - * Resolve the effective agent role for a stage. - * - * '' → derive from Stage_Display_Map (unknown stages map to ''). - * Non-empty → returned as-is (Phase-2 explicit fan-out role). - * - * @param string $stage Stage name. - * @param string $agent_role Caller-supplied role ('' = auto-resolve). - * @return string Effective agent role. - */ - private static function resolve_role( string $stage, string $agent_role ): string { - if ( '' !== $agent_role ) { - return $agent_role; - } - return (string) PRAutoBlogger_Stage_Display_Map::default_agent_role( $stage ); - } } diff --git a/includes/core/class-run-stage-writes.php b/includes/core/class-run-stage-writes.php new file mode 100644 index 0000000..ccceda4 --- /dev/null +++ b/includes/core/class-run-stage-writes.php @@ -0,0 +1,222 @@ +query( + $wpdb->prepare( + "INSERT INTO {$table} + (run_id, stage, agent_role, item_key, status, attempt, started_at, updated_at) + VALUES (%s, %s, %s, %s, 'running', 1, %s, %s) + ON DUPLICATE KEY UPDATE + attempt = IF(status = 'done', attempt, attempt + 1), + status = IF(status = 'done', 'done', 'running'), + updated_at = VALUES(updated_at)", + $run_id, + $stage, + $agent_role, + $item_key, + $now, + $now + ) + ); + } + + /** + * Mark a stage done and persist its output for resume-without-recharge. + * + * v0.20.0: a fresh completion clears the `stale` flag (and never + * touches `human_modified` — that flag is sticky run audit). When the + * site's schema predates the v0.20.0 columns (cron fired before the + * admin_init migration pass), the write self-heals by retrying the + * pre-v0.20.0 statement instead of losing the checkpoint. + * + * @param string $run_id Run UUID. + * @param string $stage Stage name. + * @param string $agent_role Fan-out dimension ('' = resolve from stage map). + * @param string $item_key Article scope. + * @param string|null $output Stage output snapshot (pruned after R days). + * @param float $cost_usd Cost attributed to this stage. + */ + 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 || ! PRAutoBlogger_Run_Stage_State::is_available() ) { + return; + } + $agent_role = self::resolve_role( $stage, $agent_role ); + global $wpdb; + $table = PRAutoBlogger_Run_Stage_State::table_name(); + $now = current_time( 'mysql' ); + $meta = null !== $output ? wp_json_encode( array( 'output' => $output ) ) : null; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$table} + (run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, stale, started_at, updated_at, finished_at) + VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, 0, %s, %s, %s) + ON DUPLICATE KEY UPDATE + status = 'done', + cost_usd = VALUES(cost_usd), + meta_json = VALUES(meta_json), + stale = 0, + updated_at = VALUES(updated_at), + finished_at = VALUES(finished_at)", + $run_id, + $stage, + $agent_role, + $item_key, + $cost_usd, + $meta, + $now, + $now, + $now + ) + ); + + // Self-healing on a half-migrated schema (no `stale` column yet): + // retry with the pre-v0.20.0 statement rather than losing the + // resume checkpoint. Same pattern as Cost_Tracker (v0.18.0). + if ( false === $result ) { + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->query( + $wpdb->prepare( + "INSERT INTO {$table} + (run_id, stage, agent_role, item_key, status, attempt, cost_usd, meta_json, started_at, updated_at, finished_at) + VALUES (%s, %s, %s, %s, 'done', 1, %f, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + status = 'done', + cost_usd = VALUES(cost_usd), + meta_json = VALUES(meta_json), + updated_at = VALUES(updated_at), + finished_at = VALUES(finished_at)", + $run_id, + $stage, + $agent_role, + $item_key, + $cost_usd, + $meta, + $now, + $now, + $now + ) + ); + } + } + + /** + * Mark a stage failed (does not demote a done stage). + * + * @param string $run_id Run UUID. + * @param string $stage Stage name. + * @param string $agent_role Fan-out dimension ('' = resolve from stage map). + * @param string $item_key Article scope. + */ + public static function fail( string $run_id, string $stage, string $agent_role = '', string $item_key = '' ): void { + if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) { + return; + } + $agent_role = self::resolve_role( $stage, $agent_role ); + global $wpdb; + $table = PRAutoBlogger_Run_Stage_State::table_name(); + $now = current_time( 'mysql' ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s + WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND status != 'done'", + $now, + $now, + $run_id, + $stage, + $agent_role, + $item_key + ) + ); + } + + /** + * Fail every still-open stage of one item. Done stages are untouched. + * + * @param string $run_id Run UUID. + * @param string $item_key Stage item key ('' = run-level stages). + */ + public static function fail_open_for_item( string $run_id, string $item_key ): void { + if ( '' === $run_id || ! PRAutoBlogger_Run_Stage_State::is_available() ) { + return; + } + global $wpdb; + $table = PRAutoBlogger_Run_Stage_State::table_name(); + $now = current_time( 'mysql' ); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} SET status = 'failed', updated_at = %s, finished_at = %s + WHERE run_id = %s AND item_key = %s AND status IN ('pending','running')", + $now, + $now, + $run_id, + $item_key + ) + ); + } + + /** + * 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). + * + * Public in v0.20.0 (was private on Run_Stage_State) so the read API + * and the rerun classes share one resolution source. + * + * @param string $stage Stage name. + * @param string $agent_role Caller-supplied role ('' = auto-resolve). + * @return string Effective agent role. + */ + public 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 ); + } +} diff --git a/includes/core/class-run-state.php b/includes/core/class-run-state.php index b5e4cd8..872b7ee 100644 --- a/includes/core/class-run-state.php +++ b/includes/core/class-run-state.php @@ -252,6 +252,41 @@ class PRAutoBlogger_Run_State { return is_array( $rows ) ? $rows : array(); } + /** + * Reopen a terminal run for operator-deliberate new spend (M3 + * re-runs). done/failed/halted -> running; finished_at cleared; the + * cost ceiling is RE-SNAPSHOTTED from the current setting — the + * deliberate re-run action adopts the operator's current per-run + * policy (can lower as well as raise), exactly as a new run would. + * Accumulated reserved/settled spend is kept: the governor treats + * re-runs as new spend on the SAME run (CPO guardrail 4). + * + * The conditional UPDATE is the atomic gate: an actively executing + * run (pending/running) can never be reopened. + * + * @param string $run_id Run UUID. + * @return bool Whether the run transitioned to running. + */ + public static function reopen( string $run_id ): bool { + if ( '' === $run_id || ! self::is_available() ) { + return false; + } + global $wpdb; + $table = self::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $updated = $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} + SET status = 'running', ceiling_usd = %f, updated_at = %s, finished_at = NULL + WHERE run_id = %s AND status IN ('done','failed','halted')", + self::ceiling_setting(), + current_time( 'mysql' ), + $run_id + ) + ); + return 1 === (int) $updated; + } + /** Reset per-request caches (tests). */ public static function flush_cache(): void { self::$table_ok = null; diff --git a/includes/core/class-stage-input-store.php b/includes/core/class-stage-input-store.php new file mode 100644 index 0000000..d9b4f40 --- /dev/null +++ b/includes/core/class-stage-input-store.php @@ -0,0 +1,269 @@ +prefix . 'prautoblogger_stage_inputs'; + } + + /** + * Whether the stage_inputs table exists. Cached per request. + * + * @return bool + */ + public static function is_available(): bool { + if ( null !== self::$table_ok ) { + return self::$table_ok; + } + global $wpdb; + if ( null === $wpdb ) { + return false; // Not cached: $wpdb may appear later in boot. + } + $table = self::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + self::$table_ok = ( $found === $table ); + return self::$table_ok; + } + + /** + * Save a human edit fork as the next version for its scope. + * + * INSERT-only by design. The version number is MAX(version)+1 for the + * scope; a concurrent-save collision on the UNIQUE key is retried + * once with a refreshed version. + * + * Side effects: one (rarely two) INSERTs into stage_inputs. + * + * @param string $run_id Run UUID. + * @param string $stage Stage name. + * @param string $agent_role Stage row's agent role. + * @param string $item_key Stage row's item key. + * @param string $request_json Edited request body (full JSON, no headers). + * @param string $author WP user login of the editor. + * @return int|null New version number, or null on failure/missing table. + */ + public static function save_fork( string $run_id, string $stage, string $agent_role, string $item_key, string $request_json, string $author ): ?int { + if ( '' === $run_id || '' === $stage || ! self::is_available() ) { + return null; + } + for ( $attempt = 0; $attempt < 2; $attempt++ ) { + $version = self::next_version( $run_id, $stage, $agent_role, $item_key ); + $inserted = self::insert_row( $run_id, $stage, $agent_role, $item_key, $version, self::SOURCE_HUMAN, $request_json, $author ); + if ( $inserted ) { + return $version; + } + } + return null; + } + + /** + * Latest human fork row for a scope, or null when none exist. + * + * @param string $run_id Run UUID. + * @param string $stage Stage name. + * @param string $agent_role Agent role. + * @param string $item_key Item key. + * @return array|null Row incl. version/request_json/author/created_at. + */ + public static function latest_fork( string $run_id, string $stage, string $agent_role, string $item_key ): ?array { + if ( '' === $run_id || ! self::is_available() ) { + return null; + } + global $wpdb; + $table = self::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $row = $wpdb->get_row( + $wpdb->prepare( + "SELECT * FROM {$table} + WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s AND source = %s + ORDER BY version DESC LIMIT 1", + $run_id, + $stage, + $agent_role, + $item_key, + self::SOURCE_HUMAN + ), + ARRAY_A + ); + return is_array( $row ) ? $row : null; + } + + /** + * Persist the idea seed for one run item (INSERT-once; replays no-op). + * + * @param string $run_id Run UUID. + * @param string $item_key Item key from Run_Stage_State::item_key_for_idea(). + * @param string $idea_json Serialized Article_Idea::to_array() payload. + * @return void + */ + public static function save_seed( string $run_id, string $item_key, string $idea_json ): void { + if ( '' === $run_id || '' === $item_key || ! self::is_available() ) { + return; + } + if ( null !== self::get_seed( $run_id, $item_key ) ) { + return; // Seed already persisted (idempotent resume). + } + self::insert_row( $run_id, '', '', $item_key, 1, self::SOURCE_SEED, $idea_json, 'pipeline' ); + } + + /** + * The idea seed JSON for one run item, or null when absent. + * + * @param string $run_id Run UUID. + * @param string $item_key Item key. + * @return string|null + */ + public static function get_seed( string $run_id, string $item_key ): ?string { + if ( '' === $run_id || ! self::is_available() ) { + return null; + } + global $wpdb; + $table = self::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $json = $wpdb->get_var( + $wpdb->prepare( + "SELECT request_json FROM {$table} + WHERE run_id = %s AND stage = '' AND item_key = %s AND source = %s LIMIT 1", + $run_id, + $item_key, + self::SOURCE_SEED + ) + ); + return ( is_string( $json ) && '' !== $json ) ? $json : null; + } + + /** + * Retention: NULL human fork bodies older than the cutoff. Seed rows + * are kept (structural, ~1KB). Version/author/flag metadata survives + * as the permanent audit trail. + * + * @param string $cutoff MySQL datetime; rows created before it are pruned. + * @return int Rows pruned. + */ + public static function prune_human_bodies( string $cutoff ): int { + if ( ! self::is_available() ) { + return 0; + } + global $wpdb; + $table = self::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $result = $wpdb->query( + $wpdb->prepare( + "UPDATE {$table} SET request_json = NULL + WHERE source = %s AND request_json IS NOT NULL AND created_at < %s", + self::SOURCE_HUMAN, + $cutoff + ) + ); + return is_numeric( $result ) ? (int) $result : 0; + } + + /** Reset per-request caches (tests). */ + public static function flush_cache(): void { + self::$table_ok = null; + } + + /** + * Next version number for a scope (1 when none exist). + * + * @param string $run_id Run UUID. + * @param string $stage Stage name. + * @param string $agent_role Agent role. + * @param string $item_key Item key. + * @return int + */ + private static function next_version( string $run_id, string $stage, string $agent_role, string $item_key ): int { + global $wpdb; + $table = self::table_name(); + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching + $max = $wpdb->get_var( + $wpdb->prepare( + "SELECT MAX(version) FROM {$table} + WHERE run_id = %s AND stage = %s AND agent_role = %s AND item_key = %s", + $run_id, + $stage, + $agent_role, + $item_key + ) + ); + return ( null !== $max ? (int) $max : 0 ) + 1; + } + + /** + * Raw row INSERT (the only write path — INSERT-only by construction). + * + * @param string $run_id Run UUID. + * @param string $stage Stage name ('' for seeds). + * @param string $agent_role Agent role. + * @param string $item_key Item key. + * @param int $version Version number. + * @param string $source 'human' or 'seed'. + * @param string $request_json Payload JSON. + * @param string $author Author label. + * @return bool Whether the row was inserted. + */ + private static function insert_row( string $run_id, string $stage, string $agent_role, string $item_key, int $version, string $source, string $request_json, string $author ): bool { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery + $inserted = $wpdb->insert( + self::table_name(), + array( + 'run_id' => $run_id, + 'stage' => $stage, + 'agent_role' => $agent_role, + 'item_key' => $item_key, + 'version' => $version, + 'source' => $source, + 'request_json' => $request_json, + 'author' => $author, + 'created_at' => current_time( 'mysql' ), + ) + ); + return false !== $inserted; + } +} diff --git a/includes/core/class-stage-replay.php b/includes/core/class-stage-replay.php new file mode 100644 index 0000000..d1741ab --- /dev/null +++ b/includes/core/class-stage-replay.php @@ -0,0 +1,189 @@ + $body Decoded request body. + * @param string $stage Stage being replayed. + * @return array Options for send_chat_completion(). + */ + public static function options_from_body( array $body, string $stage ): array { + $options = array(); + if ( isset( $body['temperature'] ) ) { + $options['temperature'] = $body['temperature']; + } + if ( isset( $body['response_format'] ) ) { + $options['response_format'] = $body['response_format']; + } + + $reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true ); + if ( $reasoning_active ) { + $options['reasoning'] = $body['reasoning']; + if ( isset( $body['max_tokens'] ) ) { + $cap = isset( $body['reasoning']['max_tokens'] ) ? (int) $body['reasoning']['max_tokens'] : 0; + // The builder raised max_tokens by the cap (completion + // headroom); hand back the caller-level value so the + // builder's re-application is not compounded. + $options['max_tokens'] = max( 1, (int) $body['max_tokens'] - $cap ); + } + } else { + // Pin reasoning OFF (same per-call override shape the + // empty-completion retry uses) so a globally-enabled + // reasoning setting cannot reshape the replay. + $options['reasoning'] = array( 'enabled' => false ); + if ( isset( $body['max_tokens'] ) ) { + $options['max_tokens'] = (int) $body['max_tokens']; + } + } + + $options['stage'] = $stage; + $options['prompt_key'] = PRAutoBlogger_Stage_Display_Map::default_prompt_key( $stage ); + + return $options; + } + + /** + * Decode + validate a fork body. Returns null when the JSON is not a + * replayable chat body (model string + non-empty messages list of + * role/content pairs). + * + * @param string $request_json Stored fork body. + * @return array|null Decoded body, or null when invalid. + */ + public static function decode_body( string $request_json ): ?array { + $body = json_decode( $request_json, true ); + if ( ! is_array( $body ) || empty( $body['model'] ) || ! is_string( $body['model'] ) ) { + return null; + } + if ( empty( $body['messages'] ) || ! is_array( $body['messages'] ) ) { + return null; + } + foreach ( $body['messages'] as $message ) { + if ( ! is_array( $message ) || ! isset( $message['role'], $message['content'] ) + || ! is_string( $message['role'] ) || ! is_string( $message['content'] ) ) { + return null; + } + } + return $body; + } + + /** + * Execute the replay call and persist its results. + * + * The caller (Rerun_Executor) has already: re-validated eligibility, + * taken the generation lock, reopened the run, demoted the stage row + * to running and stale-marked downstream. This method performs the + * governed LLM call and the success-path writes. + * + * Side effects: one OpenRouter call (governor-reserved), run_stages + * done-write, one generation_log row (with the replayed request via + * the B1 recorder), run_decisions human_modified flags. + * + * @param string $run_id Run UUID. + * @param string $stage Stage name. + * @param string $agent_role Stage row agent role. + * @param string $item_key Stage row item key. + * @param array $body Decoded fork body (from decode_body()). + * @return array{content: string, cost: float, model: string} Replay result. + * + * @throws \RuntimeException On provider failure or empty output. + * @throws PRAutoBlogger_Cost_Ceiling_Exception When the governor blocks the call. + */ + public static function run( string $run_id, string $stage, string $agent_role, string $item_key, array $body ): array { + $tracker = new PRAutoBlogger_Cost_Tracker(); + // Joins this process to the run: Run_Context (governor guard), + // ledger row, prompt pins — replay spend reserves against the + // SAME per-run ceiling as the original calls (guardrail 4). + $tracker->set_run_id( $run_id ); + + $llm = new PRAutoBlogger_OpenRouter_Provider(); + $options = self::options_from_body( $body, $stage ); + $response = $llm->send_chat_completion( $body['messages'], (string) $body['model'], $options ); + + // Same belt as the writer path: an empty replay output must fail + // the stage, never overwrite a good snapshot with nothing. + if ( '' === trim( (string) $response['content'] ) ) { + throw new \RuntimeException( + sprintf( + /* translators: 1: stage name, 2: model identifier. */ + __( 'Replay of stage "%1$s" produced empty content (model %2$s) — failing the replay instead of storing an empty output.', 'prautoblogger' ), + $stage, + (string) $response['model'] + ) + ); + } + + $cost = $llm->estimate_cost( + (string) $response['model'], + (int) $response['prompt_tokens'], + (int) $response['completion_tokens'] + ); + + PRAutoBlogger_Run_Stage_State::done( $run_id, $stage, $agent_role, $item_key, (string) $response['content'], $cost ); + + $tracker->log_api_call( + null, + $stage, + $llm->get_provider_name(), + (string) $response['model'], + (int) $response['prompt_tokens'], + (int) $response['completion_tokens'], + 'success', + '', + '' !== $agent_role ? $agent_role : null, + null + ); + + // Guardrail 2: decision rows for the replayed stage carry the + // human_modified flag (no-op when the stage has none). + PRAutoBlogger_Audit_Writer::flag_decisions_human_modified( $run_id, array( $stage ) ); + + return array( + 'content' => (string) $response['content'], + 'cost' => $cost, + 'model' => (string) $response['model'], + ); + } +} diff --git a/includes/providers/class-open-router-provider.php b/includes/providers/class-open-router-provider.php index 7685cdf..6a489ab 100644 --- a/includes/providers/class-open-router-provider.php +++ b/includes/providers/class-open-router-provider.php @@ -78,6 +78,13 @@ class PRAutoBlogger_OpenRouter_Provider implements PRAutoBlogger_LLM_Provider_In // reasoning token cap + completion headroom — lives in the builder. $body = $builder->build_body( $messages, $model, $options ); + // v0.20.0 (B1): stash the outgoing request body so the next + // log_api_call() persists it to generation_log.request_json. + // Only the BODY is recorded -- Authorization lives exclusively in + // build_headers()' return and can never reach the recorder. + // Recorded BEFORE dispatch so error/timeout rows carry it too. + PRAutoBlogger_Request_Recorder::record( $body ); + // Whether the outgoing request has reasoning enabled — drives the // empty-completion retry decision after parse. $reasoning_active = isset( $body['reasoning'] ) && false !== ( $body['reasoning']['enabled'] ?? true ); diff --git a/prautoblogger.php b/prautoblogger.php index 400c0cc..d3bc5b4 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -9,7 +9,7 @@ * Plugin Name: PRAutoBlogger * Plugin URI: https://peptiderepo.com/prautoblogger * Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics. - * Version: 0.19.4 + * Version: 0.20.0 * Requires at least: 6.0 * Requires PHP: 7.4 * Author: PeptideRepo @@ -35,8 +35,8 @@ if ( ! defined( 'ABSPATH' ) ) { | Defined here so every file in the plugin can reference paths, versions, | and limits without magic strings. */ -define( 'PRAUTOBLOGGER_VERSION', '0.19.4' ); -define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.20.0' ); +define( 'PRAUTOBLOGGER_DB_VERSION', '1.3.0' ); define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ ); define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'PRAUTOBLOGGER_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); diff --git a/templates/admin/dossier-edit-panel.php b/templates/admin/dossier-edit-panel.php new file mode 100644 index 0000000..eb76eb4 --- /dev/null +++ b/templates/admin/dossier-edit-panel.php @@ -0,0 +1,109 @@ + '', + 'messages' => array(), +); +$panel_fork = is_array( $rerun['fork'] ?? null ) ? $rerun['fork'] : null; +$panel_spend = is_array( $dossier['spend'] ?? null ) ? $dossier['spend'] : array( + 'settled' => 0.0, + 'reserved' => 0.0, + 'ceiling' => 0.0, + 'warn' => false, +); +$held_usd = (float) $panel_spend['settled'] + (float) $panel_spend['reserved']; +?> + diff --git a/templates/admin/dossier-log-stage-section.php b/templates/admin/dossier-log-stage-section.php new file mode 100644 index 0000000..e6679b3 --- /dev/null +++ b/templates/admin/dossier-log-stage-section.php @@ -0,0 +1,118 @@ + +
+ +
+

+
+ + + + + + 0 ) : ?> + $ + + +
+
+ + +
+ +
+ <?php echo esc_attr( (string) $lo_image['role'] ); ?> +
+ + + + +
+
+ +
+ +

+ + +
+ + +
+ +
diff --git a/templates/admin/dossier-page.php b/templates/admin/dossier-page.php index 7b15c24..e526992 100644 --- a/templates/admin/dossier-page.php +++ b/templates/admin/dossier-page.php @@ -101,6 +101,13 @@ $back_url = admin_url( 'admin.php?page=prautoblogger-board' ); + + > + + + > + +
0 ) : ?> @@ -142,6 +149,16 @@ $back_url = admin_url( 'admin.php?page=prautoblogger-board' ); require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-stage-section.php'; } + // M3/F3: stages that exist only in generation_log (images, + // llm_research, ...) render from their real data source. + foreach ( ( $dossier['log_only_stages'] ?? array() ) as $stage_name ) { + $log_rows = $log_index[ $stage_name ] ?? array(); + if ( empty( $log_rows ) ) { + continue; + } + require PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/dossier-log-stage-section.php'; + } + if ( empty( $stages ) ) : ?>
@@ -190,6 +207,8 @@ $back_url = admin_url( 'admin.php?page=prautoblogger-board' );
+ +

diff --git a/templates/admin/dossier-sidebar-cards.php b/templates/admin/dossier-sidebar-cards.php new file mode 100644 index 0000000..6dd1e5e --- /dev/null +++ b/templates/admin/dossier-sidebar-cards.php @@ -0,0 +1,86 @@ + 0.0, + 'reserved' => 0.0, + 'ceiling' => 0.0, + 'warn' => false, +); +$sc_held = (float) $sc_spend['settled'] + (float) $sc_spend['reserved']; +?> +
+

+
+
+
$
+ 0 ) : ?> +
+
$
+ +
+
+ 0 ) : ?> + $ + + + +
+
+ +

+ +

+ +
+ +
+

+
+ + $sc_rows ) : ?> + + + + + + + +
+ + + + +
+
diff --git a/templates/admin/dossier-stage-section.php b/templates/admin/dossier-stage-section.php index 5b7113c..244e640 100644 --- a/templates/admin/dossier-stage-section.php +++ b/templates/admin/dossier-stage-section.php @@ -3,9 +3,10 @@ * Partial: single stage section for the Article Dossier. * * Variables injected by dossier-page.php: - * $stage_row (array) -- run_stages row incl. display_output + * $stage_row (array) -- run_stages row incl. display_output + ['rerun'] panel data (M3) * $stage_name (string) -- stage key * $log_rows (array) -- generation_log rows for this stage + * $dossier (array) -- page view model (spend/eligibility/post_id) * * Security: all model output escaped via esc_html() or wp_kses_post(). * request_json escaped via esc_html() -- treat as untrusted HTML. @@ -30,9 +31,14 @@ $s_model = ( null !== $first_log && ! empty( $first_log['model'] ) ) ? $fi $s_pv = ( null !== $first_log && ! empty( $first_log['prompt_version'] ) ) ? $first_log['prompt_version'] : '—'; $has_raw_trace = ! empty( $log_rows ); $section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_role ); +$rerun = is_array( $stage_row['rerun'] ?? null ) ? $stage_row['rerun'] : array(); +$is_stale = ! empty( $rerun['stale'] ); +$is_human = ! empty( $rerun['human_modified'] ); +$attempts = (int) ( $rerun['attempt'] ?? 1 ); ?> -
@@ -46,6 +52,24 @@ $section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_ro + + > + + + > + + + 1 ) : ?> + + + + 0 ) : ?> $ @@ -123,4 +147,27 @@ $section_id = 'prab-stage-' . esc_attr( $stage_name ) . '-' . esc_attr( $s_ro
+ + + +
diff --git a/templates/admin/review-queue.php b/templates/admin/review-queue.php index 3d2cd95..287a80c 100644 --- a/templates/admin/review-queue.php +++ b/templates/admin/review-queue.php @@ -103,6 +103,18 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp + posts as $rq_post ) { + $rq_run_id = (string) get_post_meta( $rq_post->ID, '_prautoblogger_run_id', true ); + if ( '' !== $rq_run_id ) { + $rq_post_runs[ $rq_post->ID ] = $rq_run_id; + } + } + $rq_stale_runs = array_flip( PRAutoBlogger_Run_Stage_State::runs_with_stale_stages( array_values( $rq_post_runs ) ) ); + ?> @@ -137,6 +149,11 @@ $bulk_type = isset( $_GET['prautoblogger_bulk_type'] ) ? sanitize_text_field( wp
+ + + + +
diff --git a/tests/unit/Admin/BoardHumanModifiedFlagTest.php b/tests/unit/Admin/BoardHumanModifiedFlagTest.php new file mode 100644 index 0000000..2b8ebe0 --- /dev/null +++ b/tests/unit/Admin/BoardHumanModifiedFlagTest.php @@ -0,0 +1,120 @@ +col_queries = 0; + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + Functions\when( 'get_post_meta' )->alias( + static function ( $post_id, $key ) { + if ( '_prautoblogger_run_id' === $key ) { + return 'run-' . $post_id; + } + if ( '_prautoblogger_total_cost' === $key ) { + return '0.05'; + } + return ''; + } + ); + Functions\when( 'get_edit_post_link' )->justReturn( 'https://example.com/edit' ); + Functions\when( 'admin_url' )->alias( static fn( $p = '' ) => 'https://example.com/wp-admin/' . $p ); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + } + + protected function tearDown(): void { + \WP_Query::$_test_posts_queue = array(); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Build a WP_Post for the queue. */ + private function make_post( int $id ): \WP_Post { + $post = new \WP_Post(); + $post->ID = $id; + $post->post_title = 'Post ' . $id; + $post->post_date_gmt = '2026-06-12 00:00:00'; + return $post; + } + + /** + * Cards whose runs carry human_modified rows get the flag; others + * stay false; exactly ONE batched query runs for the whole column. + */ + public function test_cards_flagged_in_one_batched_query(): void { + \WP_Query::$_test_posts_queue[] = array( $this->make_post( 1 ), $this->make_post( 2 ), $this->make_post( 3 ) ); + + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' ); + $this->wpdb->method( 'get_col' )->willReturnCallback( + function ( $sql ) { + ++$this->col_queries; + $this->assertStringContainsString( 'human_modified = 1', (string) $sql ); + return array( 'run-2' ); // Only post 2's run was edited. + } + ); + + $cards = ( new \PRAutoBlogger_Board_Data_Provider() )->get_in_review_cards(); + + $this->assertCount( 3, $cards ); + $this->assertFalse( $cards[0]['human_modified'] ); + $this->assertTrue( $cards[1]['human_modified'] ); + $this->assertFalse( $cards[2]['human_modified'] ); + $this->assertSame( 1, $this->col_queries, 'one batched query, never per-card' ); + } + + /** + * Missing substrate table: flags default false, zero flag queries. + */ + public function test_missing_table_degrades_to_false(): void { + \WP_Query::$_test_posts_queue[] = array( $this->make_post( 1 ) ); + + $this->wpdb->method( 'get_var' )->willReturn( null ); + $this->wpdb->method( 'get_col' )->willReturnCallback( + function () { + ++$this->col_queries; + return array(); + } + ); + + $cards = ( new \PRAutoBlogger_Board_Data_Provider() )->get_in_review_cards(); + + $this->assertFalse( $cards[0]['human_modified'] ); + $this->assertSame( 0, $this->col_queries ); + } +} diff --git a/tests/unit/Admin/DossierActionsRerunTest.php b/tests/unit/Admin/DossierActionsRerunTest.php new file mode 100644 index 0000000..66cd08b --- /dev/null +++ b/tests/unit/Admin/DossierActionsRerunTest.php @@ -0,0 +1,103 @@ +wire_eligible_context(); + // A saved fork exists for the replay check. + $wpdb_get_row_fork = array( + 'version' => '1', + 'request_json' => '{"model":"m","messages":[{"role":"user","content":"edited"}]}', + ); + // Rebuild get_row: forks resolve, run row terminal. + $_POST = array( + 'post_id' => '99', + 'stage' => 'draft', + 'agent_role' => 'writer', + ); + // Replace the get_row wiring (fork now exists). + $wpdb = $this->create_mock_wpdb(); + $wpdb->options = 'wp_options'; + $wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + $wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) { + return ( false !== strpos( (string) $sql, 'stage_inputs' ) ) ? 'wp_prautoblogger_stage_inputs' : 'wp_prautoblogger_runs'; + } + return null; // Lock free. + } + ); + $wpdb->method( 'get_row' )->willReturnCallback( + static function ( $sql ) use ( $wpdb_get_row_fork ) { + if ( false !== strpos( (string) $sql, 'stage_inputs' ) ) { + return $wpdb_get_row_fork; + } + return array( + 'run_id' => 'run-1', + 'status' => 'done', + ); + } + ); + $wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + $GLOBALS['wpdb'] = $wpdb; + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + + $actions = new \PRAutoBlogger_Dossier_Actions(); + $this->dispatch( array( $actions, 'on_rerun_stage' ) ); + + $this->assertTrue( $this->json['ok'] ); + $this->assertTrue( $this->json['data']['queued'] ); + $this->assertCount( 1, $this->scheduled ); + $this->assertSame( \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, $this->scheduled[0]['hook'] ); + $this->assertSame( array( 'run-1', 99, 'draft', 'writer', 'idea:abc123' ), array_slice( $this->scheduled[0]['args'], 0, 5 ) ); + foreach ( $this->queries as $sql ) { + $this->assertStringNotContainsString( 'UPDATE', $sql, 'the AJAX layer must not mutate state' ); + } + } + + /** + * stage_status: read-only poll returns run status + per-stage state + * with stale/human_modified flags. + */ + public function test_stage_status_returns_stage_states(): void { + $this->wire_eligible_context(); + $this->wpdb->method( 'get_results' )->willReturn( array() ); + $_POST = array( 'post_id' => '99' ); + + $actions = new \PRAutoBlogger_Dossier_Actions(); + $this->dispatch( array( $actions, 'on_stage_status' ) ); + + $this->assertTrue( $this->json['ok'] ); + $this->assertArrayHasKey( 'run_status', $this->json['data'] ); + $this->assertArrayHasKey( 'stages', $this->json['data'] ); + } +} diff --git a/tests/unit/Admin/DossierActionsSaveInputTest.php b/tests/unit/Admin/DossierActionsSaveInputTest.php new file mode 100644 index 0000000..d5e5e70 --- /dev/null +++ b/tests/unit/Admin/DossierActionsSaveInputTest.php @@ -0,0 +1,87 @@ +wire_eligible_context( 'publish' ); + $_POST = array( + 'post_id' => '99', + 'stage' => 'draft', + 'agent_role' => 'writer', + 'messages' => '[]', + ); + + $actions = new \PRAutoBlogger_Dossier_Actions(); + $this->dispatch( array( $actions, 'on_save_input' ) ); + + $this->assertFalse( $this->json['ok'] ); + $this->assertSame( 400, $this->json['status'] ); + $this->assertCount( 0, $this->inserted ); + } + + /** + * Structure lock: changing a message ROLE (or count, or emptying a + * content) is rejected — forks are always replayable chat bodies. + */ + public function test_save_input_rejects_structure_mismatch(): void { + $this->wire_eligible_context(); + $_POST = array( + 'post_id' => '99', + 'stage' => 'draft', + 'agent_role' => 'writer', + // Role flipped user->assistant vs the base body. + 'messages' => '[{"role":"system","content":"sys"},{"role":"assistant","content":"edited"}]', + ); + + $actions = new \PRAutoBlogger_Dossier_Actions(); + $this->dispatch( array( $actions, 'on_save_input' ) ); + + $this->assertFalse( $this->json['ok'] ); + $this->assertCount( 0, $this->inserted ); + } + + /** + * Valid edit: fork v1 saved with the edited content merged into the + * base body; model/options come from the base, not the client. + */ + public function test_save_input_saves_merged_fork(): void { + $this->wire_eligible_context(); + $_POST = array( + 'post_id' => '99', + 'stage' => 'draft', + 'agent_role' => 'writer', + 'messages' => '[{"role":"system","content":"sys"},{"role":"user","content":"EDITED PROMPT"}]', + ); + + $actions = new \PRAutoBlogger_Dossier_Actions(); + $this->dispatch( array( $actions, 'on_save_input' ) ); + + $this->assertTrue( $this->json['ok'] ); + $this->assertSame( 1, $this->json['data']['version'] ); + $this->assertCount( 1, $this->inserted ); + $row = $this->inserted[0]; + $this->assertSame( 'human', $row['source'] ); + $this->assertSame( 'rhys', $row['author'] ); + $this->assertSame( 'idea:abc123', $row['item_key'] ); + $body = json_decode( (string) $row['request_json'], true ); + $this->assertSame( 'EDITED PROMPT', $body['messages'][1]['content'] ); + $this->assertSame( 'm', $body['model'] ); // From the base, not the client. + } +} diff --git a/tests/unit/Admin/DossierActionsTestCase.php b/tests/unit/Admin/DossierActionsTestCase.php new file mode 100644 index 0000000..86ba309 --- /dev/null +++ b/tests/unit/Admin/DossierActionsTestCase.php @@ -0,0 +1,212 @@ +query(). + * + * @var string[] + */ + protected array $queries = array(); + + /** + * Rows captured from $wpdb->insert(). + * + * @var array + */ + protected array $inserted = array(); + + /** + * Cron events captured from wp_schedule_single_event(). + * + * @var array + */ + protected array $scheduled = array(); + + protected function setUp(): void { + parent::setUp(); + $this->json = null; + $this->queries = array(); + $this->inserted = array(); + $this->scheduled = array(); + $_POST = array(); + + $this->wpdb = $this->create_mock_wpdb(); + $this->wpdb->options = 'wp_options'; + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + $this->wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) { + $this->inserted[] = (array) $row; + return 1; + } + ); + + Functions\when( 'check_ajax_referer' )->justReturn( true ); + Functions\when( 'current_user_can' )->justReturn( true ); + Functions\when( 'absint' )->alias( static function ( $val ) { return abs( (int) $val ); } ); + Functions\when( 'sanitize_key' )->alias( static function ( $val ) { return preg_replace( '/[^a-z0-9_\-]/', '', strtolower( (string) $val ) ); } ); + Functions\when( 'wp_unslash' )->returnArg(); + Functions\when( 'wp_schedule_single_event' )->alias( + function ( $ts, $hook, $args ) { + $this->scheduled[] = array( + 'hook' => $hook, + 'args' => $args, + ); + return true; + } + ); + Functions\when( 'spawn_cron' )->justReturn( true ); + Functions\when( 'wp_remote_post' )->justReturn( array() ); + Functions\when( 'site_url' )->justReturn( 'https://example.com' ); + Functions\when( 'wp_send_json_success' )->alias( + function ( $data = null ) { + $this->json = array( + 'ok' => true, + 'data' => $data, + ); + throw new \RuntimeException( 'json_sent' ); + } + ); + Functions\when( 'wp_send_json_error' )->alias( + function ( $data = null, $status = 0 ) { + $this->json = array( + 'ok' => false, + 'data' => $data, + 'status' => $status, + ); + throw new \RuntimeException( 'json_sent' ); + } + ); + $user = new \stdClass(); + $user->user_login = 'rhys'; + Functions\when( 'wp_get_current_user' )->justReturn( $user ); + + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + } + + protected function tearDown(): void { + $_POST = array(); + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Run an endpoint, swallowing the wp_send_json sentinel. */ + protected function dispatch( callable $endpoint ): void { + try { + $endpoint(); + } catch ( \RuntimeException $e ) { + if ( 'json_sent' !== $e->getMessage() ) { + throw $e; + } + } + } + + /** Wire post meta + a draft post + a terminal run + free lock. */ + protected function wire_eligible_context( string $post_status = 'draft', string $run_status = 'done' ): void { + Functions\when( 'get_post_meta' )->alias( + static function ( $post_id, $key ) { + if ( '_prautoblogger_run_id' === $key ) { + return 'run-1'; + } + if ( '_prautoblogger_idea_hash' === $key ) { + return 'abc123'; + } + return ''; + } + ); + $post = new \WP_Post(); + $post->ID = 99; + $post->post_status = $post_status; + Functions\when( 'get_post' )->justReturn( $post ); + + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) use ( $run_status ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'SHOW TABLES' ) ) { + if ( false !== strpos( $sql, 'stage_inputs' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + if ( false !== strpos( $sql, 'run_stages' ) ) { + return 'wp_prautoblogger_run_stages'; + } + return 'wp_prautoblogger_runs'; + } + if ( false !== strpos( $sql, 'MAX(version)' ) ) { + return null; // First fork version. + } + return null; // Lock free; no seed. + } + ); + $this->wpdb->method( 'get_row' )->willReturnCallback( + static function ( $sql ) use ( $run_status ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'stage_inputs' ) ) { + return null; // No fork yet. + } + return array( + 'run_id' => 'run-1', + 'status' => $run_status, + 'ceiling_usd' => '0.50', + ); + } + ); + // resolve_base_body falls through to the gen_log query. + $this->wpdb->method( 'get_results' )->willReturn( + array( + array( + 'id' => '11', + 'stage' => 'draft', + 'request_json' => '{"model":"m","messages":[{"role":"system","content":"sys"},{"role":"user","content":"orig"}]}', + 'response_status' => 'success', + ), + ) + ); + } +} diff --git a/tests/unit/Admin/DossierAssemblerRerunDataTest.php b/tests/unit/Admin/DossierAssemblerRerunDataTest.php new file mode 100644 index 0000000..0decff7 --- /dev/null +++ b/tests/unit/Admin/DossierAssemblerRerunDataTest.php @@ -0,0 +1,236 @@ +wpdb = $this->create_mock_wpdb(); + $this->wpdb->options = 'wp_options'; + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'get_children' )->justReturn( array() ); + Functions\when( 'wp_get_attachment_image_url' )->justReturn( '' ); + Functions\when( 'apply_filters' )->returnArg( 2 ); + + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Build the standard post + meta wiring. */ + private function wire_post(): void { + $post = new \WP_Post(); + $post->ID = 99; + $post->post_title = 'Test Article'; + $post->post_status = 'draft'; + Functions\when( 'get_post' )->justReturn( $post ); + Functions\when( 'get_post_meta' )->alias( + static function ( $post_id, $key ) { + $map = array( + '_prautoblogger_run_id' => 'run-1', + '_prautoblogger_idea_hash' => 'abc123', + '_prautoblogger_editor_verdict' => 'approved', + '_prautoblogger_article_type' => 'guide', + ); + return $map[ $key ] ?? ''; + } + ); + } + + /** Wire wpdb for: tables exist, run row, stage rows, gen_log rows, lock free. */ + private function wire_data( array $stage_rows, array $log_rows ): void { + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'SHOW TABLES' ) ) { + if ( false !== strpos( $sql, 'stage_inputs' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + if ( false !== strpos( $sql, 'run_stages' ) ) { + return 'wp_prautoblogger_run_stages'; + } + return 'wp_prautoblogger_runs'; + } + return null; // Lock free. + } + ); + $this->wpdb->method( 'get_row' )->willReturnCallback( + static function ( $sql ) { + if ( false !== strpos( (string) $sql, 'stage_inputs' ) ) { + return null; // No forks saved. + } + return array( + 'run_id' => 'run-1', + 'status' => 'done', + 'ceiling_usd' => '0.50', + 'settled_usd' => '0.30', + 'reserved_usd' => '0.15', + ); + } + ); + $this->wpdb->method( 'get_results' )->willReturnCallback( + static function ( $sql ) use ( $stage_rows, $log_rows ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'run_stages' ) ) { + return $stage_rows; + } + if ( false !== strpos( $sql, 'generation_log' ) ) { + return $log_rows; + } + return array(); // run_decisions. + } + ); + } + + /** + * The stage query is item-scoped (idea hash -> item_key IN (item, '')) + * and gen_log rows belonging to OTHER posts are dropped while + * unlinked (post_id NULL/0) rows are kept. + */ + public function test_item_scoping_and_other_post_log_exclusion(): void { + $this->wire_post(); + $captured_stage_sql = null; + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) { + return false !== strpos( (string) $sql, 'stage_inputs' ) ? 'wp_prautoblogger_stage_inputs' + : ( false !== strpos( (string) $sql, 'run_stages' ) ? 'wp_prautoblogger_run_stages' : 'wp_prautoblogger_runs' ); + } + return null; + } + ); + $this->wpdb->method( 'get_row' )->willReturn( array( 'run_id' => 'run-1', 'status' => 'done', 'ceiling_usd' => '0' ) ); + $this->wpdb->method( 'get_results' )->willReturnCallback( + static function ( $sql ) use ( &$captured_stage_sql ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'run_stages' ) ) { + $captured_stage_sql = $sql; + return array(); + } + if ( false !== strpos( $sql, 'generation_log' ) ) { + return array( + array( 'id' => '1', 'stage' => 'draft', 'post_id' => '99', 'model' => 'm', 'request_json' => null ), + array( 'id' => '2', 'stage' => 'draft', 'post_id' => '77', 'model' => 'm', 'request_json' => null ), + array( 'id' => '3', 'stage' => 'llm_research', 'post_id' => null, 'model' => 'm', 'request_json' => null ), + ); + } + return array(); + } + ); + + $dossier = ( new \PRAutoBlogger_Dossier_Data_Assembler() )->assemble( 99 ); + + $this->assertSame( 'idea:abc123', $dossier['item_key'] ); + $this->assertStringContainsString( "item_key IN (%s, '')", (string) $captured_stage_sql ); + // Post 77's draft row dropped; this post's row + unlinked research row kept. + $this->assertCount( 1, $dossier['gen_log_index']['draft'] ); + $this->assertSame( '1', $dossier['gen_log_index']['draft'][0]['id'] ); + $this->assertCount( 1, $dossier['gen_log_index']['llm_research'] ); + // F3: llm_research has no run_stages row -> exposed as log-only. + $this->assertContains( 'llm_research', $dossier['log_only_stages'] ); + } + + /** + * Per-stage rerun data: a writer stage with a recorded request is + * editable with prefill; review carries a policy reason instead; the + * spend strip reflects the ledger row (guardrail 4 visibility). + */ + public function test_stage_rerun_panel_data_and_spend(): void { + $this->wire_post(); + $stage_rows = array( + array( + 'id' => '1', + 'run_id' => 'run-1', + 'stage' => 'draft', + 'agent_role' => 'writer', + 'item_key' => 'idea:abc123', + 'status' => 'done', + 'attempt' => '1', + 'cost_usd' => '0.01', + 'meta_json' => '{"output":"text"}', + 'stale' => '0', + 'human_modified' => '1', + ), + array( + 'id' => '2', + 'run_id' => 'run-1', + 'stage' => 'review', + 'agent_role' => 'editor', + 'item_key' => 'idea:abc123', + 'status' => 'done', + 'attempt' => '1', + 'cost_usd' => '0.005', + 'meta_json' => null, + 'stale' => '1', + ), + ); + $log_rows = array( + array( + 'id' => '10', + 'stage' => 'draft', + 'post_id' => '99', + 'model' => 'google/gemini-2.5-flash-lite', + 'request_json' => '{"model":"google/gemini-2.5-flash-lite","messages":[{"role":"user","content":"draft prompt"}]}', + 'response_status' => 'success', + ), + ); + $this->wire_data( $stage_rows, $log_rows ); + + $dossier = ( new \PRAutoBlogger_Dossier_Data_Assembler() )->assemble( 99 ); + + $draft = $dossier['stages']['draft:writer']['rerun']; + $this->assertTrue( $draft['editable'] ); + $this->assertSame( 'draft prompt', $draft['prefill']['messages'][0]['content'] ); + $this->assertTrue( $draft['human_modified'] ); + $this->assertTrue( $dossier['human_modified_any'] ); + + $review = $dossier['stages']['review:editor']['rerun']; + $this->assertFalse( $review['editable'] ); + $this->assertStringContainsString( 'Re-run from here', $review['edit_reason'] ); + $this->assertTrue( $review['stale'] ); + $this->assertTrue( $review['rerun_from'] ); + $this->assertTrue( $dossier['stale_any'] ); + + $this->assertSame( 0.30, $dossier['spend']['settled'] ); + $this->assertSame( 0.15, $dossier['spend']['reserved'] ); + $this->assertSame( 0.50, $dossier['spend']['ceiling'] ); + $this->assertTrue( $dossier['spend']['warn'] ); // 0.45/0.50 = 90% >= 80%. + } +} diff --git a/tests/unit/Admin/DossierDataAssemblerTest.php b/tests/unit/Admin/DossierDataAssemblerTest.php index 82355d4..2320227 100644 --- a/tests/unit/Admin/DossierDataAssemblerTest.php +++ b/tests/unit/Admin/DossierDataAssemblerTest.php @@ -37,6 +37,14 @@ class DossierDataAssemblerTest extends BaseTestCase { // Stub PRAutoBlogger_Run_Stage_State::is_available() to return true. // Static method -- stubbed via wpdb table check returning the table name. \PRAutoBlogger_Run_Stage_State::flush_cache(); + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + + // v0.20.0 additions: image data + eligibility paths. + Functions\when( 'get_post_thumbnail_id' )->justReturn( 0 ); + Functions\when( 'get_children' )->justReturn( array() ); + Functions\when( 'wp_get_attachment_image_url' )->justReturn( '' ); + Functions\when( 'apply_filters' )->returnArg( 2 ); // table_name returns wp_prautoblogger_run_stages. $this->wpdb->prefix = 'wp_'; diff --git a/tests/unit/Admin/DossierMenuRegistrationTestCase.php b/tests/unit/Admin/DossierMenuRegistrationTestCase.php index 7f06bcb..b665865 100644 --- a/tests/unit/Admin/DossierMenuRegistrationTestCase.php +++ b/tests/unit/Admin/DossierMenuRegistrationTestCase.php @@ -51,6 +51,11 @@ abstract class DossierMenuRegistrationTestCase extends BaseTestCase { $this->captured_menu_slug = null; $this->registration_hookname = null; + // v0.20.0: the dossier enqueue path localizes the edit/re-run config. + Functions\when( 'wp_localize_script' )->justReturn( true ); + Functions\when( 'wp_create_nonce' )->justReturn( 'test-nonce' ); + Functions\when( 'absint' )->alias( static function ( $val ) { return abs( (int) $val ); } ); + // Spy stub: tracks call order, captures slugs, returns a hookname that // reflects how WordPress actually resolves it for known parents. Functions\when( 'add_menu_page' )->alias( diff --git a/tests/unit/BaseTestCase.php b/tests/unit/BaseTestCase.php index bef09ff..0cdeb22 100644 --- a/tests/unit/BaseTestCase.php +++ b/tests/unit/BaseTestCase.php @@ -147,7 +147,7 @@ abstract class BaseTestCase extends TestCase { */ protected function create_mock_wpdb() { $wpdb = $this->getMockBuilder( \stdClass::class ) - ->addMethods( [ 'prepare', 'get_var', 'get_results', 'insert', 'query', 'get_row', 'update' ] ) + ->addMethods( [ 'prepare', 'get_var', 'get_results', 'insert', 'query', 'get_row', 'get_col', 'update' ] ) ->getMock(); $wpdb->prefix = 'wp_'; diff --git a/tests/unit/Core/CostTrackerRequestJsonTest.php b/tests/unit/Core/CostTrackerRequestJsonTest.php new file mode 100644 index 0000000..c37014d --- /dev/null +++ b/tests/unit/Core/CostTrackerRequestJsonTest.php @@ -0,0 +1,181 @@ +insert(). + * + * @var array> + */ + private array $inserted = array(); + + protected function setUp(): void { + parent::setUp(); + $this->inserted = array(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->insert_id = 7; + $this->wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) { + // Capture generation_log rows only — the Logger also + // inserts (event_log) when e.g. Pricing warns on an + // unknown model; those rows are not under test. + if ( false !== strpos( (string) $table, 'generation_log' ) ) { + $this->inserted[] = (array) $row; + } + return 1; + } + ); + $this->stub_get_option( array() ); + \PRAutoBlogger_Request_Recorder::clear(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Request_Recorder::clear(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * log_api_call() persists the recorder stash into request_json. + */ + public function test_log_api_call_persists_recorded_request_json(): void { + \PRAutoBlogger_Request_Recorder::record( + array( + 'model' => 'google/gemini-2.5-flash-lite', + 'messages' => array( array( 'role' => 'user', 'content' => 'draft prompt' ) ), + ) + ); + + ( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( + null, + 'draft', + 'OpenRouter', + 'google/gemini-2.5-flash-lite', + 100, + 500 + ); + + $this->assertCount( 1, $this->inserted ); + $row = $this->inserted[0]; + $this->assertArrayHasKey( 'request_json', $row ); + $decoded = json_decode( (string) $row['request_json'], true ); + $this->assertSame( 'draft prompt', $decoded['messages'][0]['content'] ); + } + + /** + * Consume-once isolation: a second log row written with no new send + * in between must get NULL, never the previous call's body. + */ + public function test_second_log_row_without_new_record_gets_null(): void { + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm1' ) ); + + $tracker = new \PRAutoBlogger_Cost_Tracker(); + $tracker->log_api_call( null, 'draft', 'OpenRouter', 'm1', 10, 10 ); + $tracker->log_api_call( null, 'polish', 'OpenRouter', 'm1', 10, 10 ); + + $this->assertCount( 2, $this->inserted ); + $this->assertNotNull( $this->inserted[0]['request_json'] ); + $this->assertNull( $this->inserted[1]['request_json'] ); + } + + /** + * Historical behavior preserved: nothing recorded -> request_json NULL. + */ + public function test_log_api_call_without_stash_writes_null(): void { + ( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( + null, + 'analysis', + 'OpenRouter', + 'google/gemini-2.5-flash-lite', + 10, + 10 + ); + + $this->assertCount( 1, $this->inserted ); + $this->assertNull( $this->inserted[0]['request_json'] ); + } + + /** + * The persisted payload can never contain credential material — the + * provider records only build_body() output (headers are separate). + * Belt-and-braces audit on the exact column value. + */ + public function test_persisted_request_json_contains_no_auth_header(): void { + $this->stub_get_option( array() ); + $builder = new \PRAutoBlogger_OpenRouter_Request_Builder(); + \PRAutoBlogger_Request_Recorder::record( + $builder->build_body( + array( array( 'role' => 'user', 'content' => 'x' ) ), + 'google/gemini-2.5-flash-lite', + array( 'max_tokens' => 100 ) + ) + ); + + ( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( + null, + 'draft', + 'OpenRouter', + 'google/gemini-2.5-flash-lite', + 10, + 10 + ); + + $json = (string) $this->inserted[0]['request_json']; + $this->assertStringNotContainsString( 'Authorization', $json ); + $this->assertStringNotContainsString( 'Bearer', $json ); + $this->assertStringNotContainsString( 'sk-or-', $json ); + } + + /** + * The half-migrated-schema legacy retry (v0.18.0 pattern) keeps + * request_json — the column predates the audit columns (v1.1.0 + * schema) and exists on every site. + */ + public function test_legacy_schema_retry_keeps_request_json(): void { + $this->inserted = array(); + $wpdb = $this->create_mock_wpdb(); + $wpdb->insert_id = 7; + $calls = 0; + $wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) use ( &$calls ) { + if ( false === strpos( (string) $table, 'generation_log' ) ) { + return 1; // Logger/event_log noise — not under test. + } + ++$calls; + $this->inserted[] = (array) $row; + return 1 === $calls ? false : 1; // First insert fails (missing v0.18.0 columns). + } + ); + $GLOBALS['wpdb'] = $wpdb; + + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm1' ) ); + ( new \PRAutoBlogger_Cost_Tracker() )->log_api_call( null, 'draft', 'OpenRouter', 'm1', 10, 10 ); + + $this->assertCount( 2, $this->inserted ); + $retry_row = $this->inserted[1]; + $this->assertArrayNotHasKey( 'agent_role', $retry_row ); + $this->assertArrayNotHasKey( 'prompt_version', $retry_row ); + $this->assertArrayHasKey( 'request_json', $retry_row ); + $this->assertNotNull( $retry_row['request_json'] ); + } +} diff --git a/tests/unit/Core/PublisherRerunUpdateTest.php b/tests/unit/Core/PublisherRerunUpdateTest.php new file mode 100644 index 0000000..2ae02d8 --- /dev/null +++ b/tests/unit/Core/PublisherRerunUpdateTest.php @@ -0,0 +1,134 @@ + + */ + private array $updated_meta = array(); + + protected function setUp(): void { + parent::setUp(); + + $this->idea = new \PRAutoBlogger_Article_Idea( $this->get_article_idea_fixture() ); + $this->review = new \PRAutoBlogger_Editorial_Review( $this->get_editorial_review_fixture() ); + + $this->stub_get_option( array() ); + Functions\when( 'sanitize_text_field' )->returnArg(); + Functions\when( 'wp_kses_post' )->returnArg(); + Functions\when( 'wp_strip_all_tags' )->alias( + static function ( $text ) { + return trim( strip_tags( (string) $text ) ); + } + ); + Functions\when( 'is_wp_error' )->justReturn( false ); + Functions\when( 'apply_filters' )->returnArg( 2 ); + + $this->updated_args = null; + $this->updated_meta = array(); + Functions\when( 'wp_update_post' )->alias( + function ( $args ) { + $this->updated_args = (array) $args; + return $args['ID'] ?? 0; + } + ); + Functions\when( 'update_post_meta' )->alias( + function ( $post_id, $key, $value ) { + $this->updated_meta[ (string) $key ] = $value; + return true; + } + ); + + // find_existing_post() resolves post 55 for this (run, idea). + $wpdb = $this->create_mock_wpdb(); + $wpdb->method( 'prepare' )->willReturn( 'prepared' ); + $wpdb->method( 'get_var' )->willReturn( '55' ); + $GLOBALS['wpdb'] = $wpdb; + } + + protected function tearDown(): void { + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Stub get_post for post 55 with a status. */ + private function stub_existing_post( string $status ): void { + $post = new \WP_Post(); + $post->ID = 55; + $post->post_status = $status; + Functions\when( 'get_post' )->justReturn( $post ); + } + + /** + * Re-run output refreshes the existing draft: content + title are + * updated, verdict meta refreshed — and the post status is NEVER + * touched (a re-run must not publish; publication stays an explicit + * operator action). + */ + public function test_rerun_refreshes_existing_draft_without_status_change(): void { + $this->stub_existing_post( 'draft' ); + Functions\when( 'wp_insert_post' )->alias( + static function () { + throw new \RuntimeException( 'wp_insert_post must not be called for an existing post' ); + } + ); + + $publisher = new \PRAutoBlogger_Publisher(); + $post_id = $publisher->save_as_draft( '

Regenerated content

', $this->idea, $this->review, 'run-1' ); + + $this->assertSame( 55, $post_id ); + $this->assertIsArray( $this->updated_args ); + $this->assertSame( 55, $this->updated_args['ID'] ); + $this->assertStringContainsString( 'Regenerated content', (string) $this->updated_args['post_content'] ); + $this->assertArrayNotHasKey( 'post_status', $this->updated_args ); + $this->assertSame( $this->review->get_verdict(), $this->updated_meta['_prautoblogger_editor_verdict'] ); + $this->assertArrayHasKey( '_prautoblogger_generated_at', $this->updated_meta ); + } + + /** + * Published post: frozen — returned untouched, no wp_update_post, no + * meta writes, no duplicate insert (guardrail 5 at the data layer). + */ + public function test_published_post_is_never_updated(): void { + $this->stub_existing_post( 'publish' ); + Functions\when( 'wp_insert_post' )->alias( + static function () { + throw new \RuntimeException( 'wp_insert_post must not be called for an existing post' ); + } + ); + + $publisher = new \PRAutoBlogger_Publisher(); + $post_id = $publisher->save_as_draft( '

Regenerated content

', $this->idea, $this->review, 'run-1' ); + + $this->assertSame( 55, $post_id ); + $this->assertNull( $this->updated_args ); + $this->assertSame( array(), $this->updated_meta ); + } +} diff --git a/tests/unit/Core/RequestRecorderTest.php b/tests/unit/Core/RequestRecorderTest.php new file mode 100644 index 0000000..891b805 --- /dev/null +++ b/tests/unit/Core/RequestRecorderTest.php @@ -0,0 +1,120 @@ + 'google/gemini-2.5-flash-lite', + 'messages' => array( array( 'role' => 'user', 'content' => 'hi' ) ), + ) + ); + + $first = \PRAutoBlogger_Request_Recorder::consume(); + $this->assertIsString( $first ); + $decoded = json_decode( $first, true ); + $this->assertSame( 'google/gemini-2.5-flash-lite', $decoded['model'] ); + $this->assertSame( 'hi', $decoded['messages'][0]['content'] ); + + // Consume-once: the slot is cleared by the first read. + $this->assertNull( \PRAutoBlogger_Request_Recorder::consume() ); + } + + /** + * record() overwrites the previous stash — a stale body from an + * unlogged earlier call can never survive into the next dispatch. + */ + public function test_record_overwrites_previous_stash(): void { + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'first/model' ) ); + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'second/model' ) ); + + $decoded = json_decode( (string) \PRAutoBlogger_Request_Recorder::consume(), true ); + $this->assertSame( 'second/model', $decoded['model'] ); + } + + /** + * consume() with nothing recorded returns null (historical rows / + * non-chat log writes keep request_json NULL). + */ + public function test_consume_without_record_returns_null(): void { + $this->assertNull( \PRAutoBlogger_Request_Recorder::consume() ); + } + + /** + * clear() empties the slot. + */ + public function test_clear_empties_slot(): void { + \PRAutoBlogger_Request_Recorder::record( array( 'model' => 'm' ) ); + \PRAutoBlogger_Request_Recorder::clear(); + $this->assertNull( \PRAutoBlogger_Request_Recorder::consume() ); + } + + /** + * The full record path for a body built by the real Request_Builder + * from poisoned options can never contain credential material: + * build_body() copies only whitelisted keys and headers are built + * separately, so the recorder's JSON has no Authorization shape. + */ + public function test_recorded_builder_body_never_contains_auth_material(): void { + $this->stub_get_option( array() ); + + $builder = new \PRAutoBlogger_OpenRouter_Request_Builder(); + $body = $builder->build_body( + array( array( 'role' => 'user', 'content' => 'write about BPC-157' ) ), + 'google/gemini-2.5-flash-lite', + array( + 'temperature' => 0.7, + 'max_tokens' => 4000, + // Hostile/accidental injection attempts — must all be dropped. + 'Authorization' => 'Bearer sk-or-secret-key-123', + 'headers' => array( 'Authorization' => 'Bearer sk-or-secret-key-123' ), + 'api_key' => 'sk-or-secret-key-123', + // Caller metadata — never sent upstream, never recorded. + 'stage' => 'draft', + 'prompt_key' => 'content.draft', + 'empty_retry' => true, + ) + ); + \PRAutoBlogger_Request_Recorder::record( $body ); + + $json = (string) \PRAutoBlogger_Request_Recorder::consume(); + $this->assertStringNotContainsString( 'Authorization', $json ); + $this->assertStringNotContainsString( 'Bearer', $json ); + $this->assertStringNotContainsString( 'sk-or-', $json ); + $this->assertStringNotContainsString( 'api_key', $json ); + $this->assertStringNotContainsString( 'empty_retry', $json ); + $this->assertStringNotContainsString( 'prompt_key', $json ); + + $decoded = json_decode( $json, true ); + $this->assertSame( + array( 'model', 'messages', 'temperature', 'max_tokens' ), + array_keys( $decoded ) + ); + } +} diff --git a/tests/unit/Core/RerunEligibilityTest.php b/tests/unit/Core/RerunEligibilityTest.php new file mode 100644 index 0000000..2005de3 --- /dev/null +++ b/tests/unit/Core/RerunEligibilityTest.php @@ -0,0 +1,182 @@ +wpdb = $this->create_mock_wpdb(); + $this->wpdb->options = 'wp_options'; + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + return $sql . ' /* ' . implode( ',', array_map( 'strval', array_filter( $args, 'is_scalar' ) ) ) . ' */'; + } + ); + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * Wire the mock for: runs table exists, run row with given status, + * generation lock free. + */ + private function wire_run( string $run_status ): void { + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'SHOW TABLES' ) ) { + // Both runs + stage_inputs probes succeed. + if ( false !== strpos( $sql, 'stage_inputs' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + return 'wp_prautoblogger_runs'; + } + return null; // Lock option absent -> lock free; seed absent. + } + ); + $this->wpdb->method( 'get_row' )->willReturn( + array( + 'run_id' => 'run-1', + 'status' => $run_status, + 'ceiling_usd' => '0.50', + ) + ); + } + + /** Stub get_post to return a post with the given status. */ + private function stub_post( string $status ): void { + $post = new \WP_Post(); + $post->ID = 99; + $post->post_status = $status; + Functions\when( 'get_post' )->justReturn( $post ); + } + + /** + * Published, scheduled, and private posts are all frozen — no + * edit+rerun (CPO guardrail 5). Draft and pending are not. + */ + public function test_published_post_lockout_covers_all_frozen_statuses(): void { + foreach ( array( 'publish', 'future', 'private' ) as $frozen ) { + $post = new \WP_Post(); + $post->post_status = $frozen; + $this->assertTrue( \PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ), "status {$frozen} must freeze" ); + } + foreach ( array( 'draft', 'pending' ) as $open ) { + $post = new \WP_Post(); + $post->post_status = $open; + $this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::post_frozen( $post ), "status {$open} must not freeze" ); + } + // A run with no post yet is not frozen. + $this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::post_frozen( null ) ); + } + + /** + * check() rejects a published post with the WP-editor redirect copy + * (server-side enforcement, not just hidden UI). + */ + public function test_check_rejects_published_post(): void { + $this->wire_run( 'done' ); + $this->stub_post( 'publish' ); + + $verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 ); + + $this->assertFalse( $verdict['ok'] ); + $this->assertStringContainsString( 'published', $verdict['reason'] ); + } + + /** + * check() rejects an actively executing run — "in progress" in the + * CPO sense means unpublished workflow state, not mid-execution. + */ + public function test_check_rejects_actively_executing_run(): void { + $this->wire_run( 'running' ); + $this->stub_post( 'draft' ); + + $verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 ); + + $this->assertFalse( $verdict['ok'] ); + $this->assertStringContainsString( 'currently executing', $verdict['reason'] ); + } + + /** + * A terminal run on an unpublished post is eligible: done, failed + * and halted (held) runs can all be re-run. + */ + public function test_check_accepts_terminal_runs_on_unpublished_posts(): void { + foreach ( array( 'done', 'failed', 'halted' ) as $status ) { + $wpdb = $this->create_mock_wpdb(); + $wpdb->options = 'wp_options'; + $wpdb->method( 'prepare' )->willReturnCallback( static fn( $sql, ...$a ) => (string) $sql ); + $wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + return ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) ? 'wp_prautoblogger_runs' : null; + } + ); + $wpdb->method( 'get_row' )->willReturn( array( 'status' => $status ) ); + $GLOBALS['wpdb'] = $wpdb; + \PRAutoBlogger_Run_State::flush_cache(); + $this->stub_post( 'draft' ); + + $verdict = \PRAutoBlogger_Rerun_Eligibility::check( 'run-1', 99 ); + $this->assertTrue( $verdict['ok'], "run status {$status} must be eligible: {$verdict['reason']}" ); + } + } + + /** + * Editable-stage policy: writer chat stages only. review/analysis/ + * image stages get the replay affordance refused at the policy layer. + */ + public function test_editable_stage_policy(): void { + foreach ( array( 'outline', 'draft', 'polish' ) as $stage ) { + $this->assertTrue( \PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ); + } + foreach ( array( 'review', 'publish', 'analysis', 'research', 'llm_research', 'image_a', 'image_b', 'curate' ) as $stage ) { + $this->assertFalse( \PRAutoBlogger_Rerun_Eligibility::is_editable_stage( $stage ) ); + } + + $verdict = \PRAutoBlogger_Rerun_Eligibility::check_replay( 'run-1', 99, 'review', 'editor', 'idea:abc' ); + $this->assertFalse( $verdict['ok'] ); + } + + /** + * Downstream/rebuild sets follow the canonical chain — these sets + * drive stale marking and demotion, so order and bounds matter. + */ + public function test_downstream_and_rebuild_sets(): void { + $this->assertSame( array( 'polish', 'review', 'publish' ), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'draft' ) ); + $this->assertSame( array( 'publish' ), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'review' ) ); + $this->assertSame( array(), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'publish' ) ); + $this->assertSame( array(), \PRAutoBlogger_Rerun_Eligibility::downstream_of( 'image_a' ) ); + $this->assertSame( array( 'review', 'publish' ), \PRAutoBlogger_Rerun_Eligibility::rebuild_set( 'review' ) ); + $this->assertSame( + array( 'outline', 'draft', 'polish', 'review', 'publish' ), + \PRAutoBlogger_Rerun_Eligibility::rebuild_set( 'outline' ) + ); + } +} diff --git a/tests/unit/Core/RerunExecutorTest.php b/tests/unit/Core/RerunExecutorTest.php new file mode 100644 index 0000000..db3c57d --- /dev/null +++ b/tests/unit/Core/RerunExecutorTest.php @@ -0,0 +1,217 @@ +query(). + * + * @var string[] + */ + private array $queries = array(); + + /** + * Transients captured from set_transient(). + * + * @var array + */ + private array $transients = array(); + + protected function setUp(): void { + parent::setUp(); + $this->queries = array(); + $this->transients = array(); + $this->wpdb = $this->create_mock_wpdb(); + $this->wpdb->options = 'wp_options'; + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + Functions\when( 'set_transient' )->alias( + function ( $key, $value, $ttl = 0 ) { + $this->transients[] = (array) $value; + return true; + } + ); + // Monthly budget 0 = no limit (fast false path). + $this->stub_get_option( array( 'prautoblogger_monthly_budget_usd' => '0' ) ); + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_State::flush_cache(); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** SHOW TABLES probes succeed for every substrate table. */ + private function tables_exist(): void { + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'SHOW TABLES' ) ) { + if ( false !== strpos( $sql, 'stage_inputs' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + if ( false !== strpos( $sql, 'run_stages' ) ) { + return 'wp_prautoblogger_run_stages'; + } + return 'wp_prautoblogger_runs'; + } + return null; + } + ); + } + + /** + * queue() never executes anything: it schedules a single cron event + * with a uniqueness token appended and broadcasts a 'running' status + * transient so board/dossier polling shows the queued state + * (chained-cron semantics — the CPO hard constraint). + */ + public function test_queue_schedules_cron_and_broadcasts_queued_state(): void { + $scheduled = null; + Functions\when( 'wp_schedule_single_event' )->alias( + static function ( $ts, $hook, $args ) use ( &$scheduled ) { + $scheduled = array( + 'hook' => $hook, + 'args' => $args, + ); + return true; + } + ); + Functions\when( 'spawn_cron' )->justReturn( true ); + Functions\when( 'wp_remote_post' )->justReturn( array() ); + Functions\when( 'site_url' )->justReturn( 'https://example.com' ); + + \PRAutoBlogger_Rerun_Job_Support::queue( + \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, + array( 'run-1', 99, 'draft', 'writer', 'idea:abc' ), + 'Queued: re-running Draft…' + ); + + $this->assertSame( \PRAutoBlogger_Rerun_Executor::REPLAY_ACTION, $scheduled['hook'] ); + $this->assertCount( 6, $scheduled['args'] ); // 5 args + uniqueness token. + $this->assertSame( 'run-1', $scheduled['args'][0] ); + $this->assertCount( 1, $this->transients ); + $this->assertSame( 'running', $this->transients[0]['status'] ); + $this->assertSame( 'Queued: re-running Draft…', $this->transients[0]['stage'] ); + } + + /** + * Under-lock re-validation: a post published between click and cron + * pickup aborts the replay with an error transient and ZERO + * run_stages mutations (guardrail 5 cannot be raced). + */ + public function test_replay_job_aborts_on_frozen_post_without_mutations(): void { + $this->tables_exist(); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; // Lock cleanup + acquire succeed. + } + ); + $post = new \WP_Post(); + $post->ID = 99; + $post->post_status = 'publish'; + Functions\when( 'get_post' )->justReturn( $post ); + + ( new \PRAutoBlogger_Rerun_Executor() )->on_replay_job( 'run-1', 99, 'draft', 'writer', 'idea:abc' ); + + foreach ( $this->queries as $sql ) { + $this->assertStringNotContainsString( 'run_stages', $sql, 'no stage mutation may happen on a frozen post' ); + $this->assertStringNotContainsString( 'run_id = %s AND status IN', $sql, 'the run must not be reopened' ); + } + $last = end( $this->transients ); + $this->assertSame( 'error', $last['status'] ); + $this->assertStringContainsString( 'published', $last['message'] ); + } + + /** + * When the stage demotion fails after the run was reopened (e.g. the + * row is concurrently 'running'), the previous terminal status is + * restored — the run cannot be left dangling in 'running' for the + * reaper to misread. + */ + public function test_replay_job_restores_terminal_status_when_restart_fails(): void { + $this->tables_exist(); + $this->wpdb->method( 'get_row' )->willReturnCallback( + static function ( $sql ) { + $sql = (string) $sql; + if ( false !== strpos( $sql, 'stage_inputs' ) ) { + return array( + 'version' => '2', + 'request_json' => '{"model":"m","messages":[{"role":"user","content":"edited"}]}', + ); + } + return array( + 'run_id' => 'run-1', + 'status' => 'done', + 'ceiling_usd' => '0.50', + ); + } + ); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $sql = (string) $sql; + $this->queries[] = $sql; + if ( false !== strpos( $sql, 'attempt = attempt + 1' ) ) { + return 0; // restart() fails — row not demotable. + } + return 1; + } + ); + $post = new \WP_Post(); + $post->ID = 99; + $post->post_status = 'draft'; + Functions\when( 'get_post' )->justReturn( $post ); + + ( new \PRAutoBlogger_Rerun_Executor() )->on_replay_job( 'run-1', 99, 'draft', 'writer', 'idea:abc' ); + + $reopened = false; + $restored = false; + foreach ( $this->queries as $sql ) { + if ( false !== strpos( $sql, "ceiling_usd = %f" ) ) { + $reopened = true; + } + // restore_terminal -> mark_status('done') over the reopened 'running' row. + if ( false !== strpos( $sql, 'SET status = %s' ) && false !== strpos( $sql, 'done' ) + && false !== strpos( $sql, "status IN ('pending','running')" ) ) { + $restored = true; + } + } + $this->assertTrue( $reopened, 'run must have been reopened before restart' ); + $this->assertTrue( $restored, 'terminal status must be restored after the aborted demotion' ); + $last = end( $this->transients ); + $this->assertSame( 'error', $last['status'] ); + } +} diff --git a/tests/unit/Core/RunStageRerunStateTest.php b/tests/unit/Core/RunStageRerunStateTest.php new file mode 100644 index 0000000..4176793 --- /dev/null +++ b/tests/unit/Core/RunStageRerunStateTest.php @@ -0,0 +1,185 @@ +query(). + * + * @var string[] + */ + private array $queries = array(); + + protected function setUp(): void { + parent::setUp(); + $this->queries = array(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_run_stages' ); + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + \PRAutoBlogger_Run_Stage_State::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_Stage_State::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Capture queries, all succeeding. */ + private function capture_queries(): void { + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + } + + /** + * done() writes stale = 0 on both INSERT and duplicate-key paths and + * never references human_modified (sticky audit — set only by + * Rerun_State::restart()). + */ + public function test_done_clears_stale_and_never_touches_human_modified(): void { + $this->capture_queries(); + + \PRAutoBlogger_Run_Stage_State::done( 'run-1', 'draft', '', 'idea:abc', 'output text', 0.01 ); + + $this->assertCount( 1, $this->queries ); + $sql = $this->queries[0]; + $this->assertStringContainsString( 'stale', $sql ); + $this->assertStringContainsString( 'stale = 0', $sql ); + $this->assertStringNotContainsString( 'human_modified', $sql ); + } + + /** + * Half-migrated schema (no stale column yet): the failed write is + * retried with the pre-v0.20.0 statement — the resume checkpoint is + * never lost (Cost_Tracker v0.18.0 retry pattern). + */ + public function test_done_self_heals_on_missing_stale_column(): void { + $calls = 0; + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) use ( &$calls ) { + $this->queries[] = (string) $sql; + ++$calls; + return 1 === $calls ? false : 1; // First write fails (unknown column). + } + ); + + \PRAutoBlogger_Run_Stage_State::done( 'run-1', 'draft', '', 'idea:abc', 'output', 0.01 ); + + $this->assertCount( 2, $this->queries ); + $this->assertStringContainsString( 'stale', $this->queries[0] ); + $this->assertStringNotContainsString( 'stale', $this->queries[1] ); + $this->assertStringContainsString( "status = 'done'", $this->queries[1] ); + } + + /** + * restart() demotes terminal/pending rows only — a 'running' row can + * never be restarted (the WHERE excludes it), the attempt counter is + * bumped, stale clears, and human_modified can only be raised + * (IF(...,1,human_modified)), never reset. + */ + public function test_restart_rules(): void { + $this->capture_queries(); + + $ok = \PRAutoBlogger_Run_Stage_Rerun_State::restart( 'run-1', 'draft', 'writer', 'idea:abc', true ); + + $this->assertTrue( $ok ); + $sql = $this->queries[0]; + $this->assertStringContainsString( "status = 'running'", $sql ); + $this->assertStringContainsString( 'attempt = attempt + 1', $sql ); + $this->assertStringContainsString( 'stale = 0', $sql ); + $this->assertStringContainsString( 'IF(%d = 1, 1, human_modified)', $sql ); + $this->assertStringContainsString( "IN ('done','failed','halted','pending')", $sql ); + $this->assertStringNotContainsString( "'running')", str_replace( "status = 'running'", '', $sql ) ); + } + + /** + * restart() reports false when no row matched (e.g. concurrently + * running stage) so the executor can abort cleanly. + */ + public function test_restart_returns_false_when_no_row_demoted(): void { + $this->wpdb->method( 'query' )->willReturn( 0 ); + + $this->assertFalse( + \PRAutoBlogger_Run_Stage_Rerun_State::restart( 'run-1', 'draft', 'writer', 'idea:abc', false ) + ); + } + + /** + * mark_stale() flags only 'done' rows of the given stages — pending/ + * failed rows are not "stale", they are simply not run. Nothing is + * demoted (no status write): guardrail 3's no-silent-auto-rerun holds + * by construction. + */ + public function test_mark_stale_targets_done_rows_only_and_never_demotes(): void { + $this->capture_queries(); + + $count = \PRAutoBlogger_Run_Stage_Rerun_State::mark_stale( 'run-1', 'idea:abc', array( 'polish', 'review', 'publish' ) ); + + $this->assertSame( 1, $count ); + $sql = $this->queries[0]; + $this->assertStringContainsString( 'SET stale = 1', $sql ); + $this->assertStringContainsString( "status = 'done'", $sql ); + $this->assertStringContainsString( 'IN (%s,%s,%s)', $sql ); + $this->assertStringNotContainsString( "SET status", $sql ); + } + + /** + * demote_to_pending() sets pending+stale, keeps meta_json/attempt/ + * human_modified untouched (audit survives), and never touches a + * 'running' row. + */ + public function test_demote_to_pending_preserves_audit_columns(): void { + $this->capture_queries(); + + \PRAutoBlogger_Run_Stage_Rerun_State::demote_to_pending( 'run-1', 'idea:abc', array( 'review', 'publish' ) ); + + $sql = $this->queries[0]; + $this->assertStringContainsString( "SET status = 'pending', stale = 1", $sql ); + $this->assertStringContainsString( "status != 'running'", $sql ); + $this->assertStringNotContainsString( 'meta_json', $sql ); + $this->assertStringNotContainsString( 'attempt', $sql ); + $this->assertStringNotContainsString( 'human_modified', $sql ); + } + + /** + * Empty stage lists and missing tables degrade to 0 / no queries. + */ + public function test_noop_paths(): void { + $this->capture_queries(); + + $this->assertSame( 0, \PRAutoBlogger_Run_Stage_Rerun_State::mark_stale( 'run-1', 'i', array() ) ); + $this->assertSame( 0, \PRAutoBlogger_Run_Stage_Rerun_State::demote_to_pending( '', 'i', array( 'draft' ) ) ); + $this->assertCount( 0, $this->queries ); + } +} diff --git a/tests/unit/Core/RunStateReopenTest.php b/tests/unit/Core/RunStateReopenTest.php new file mode 100644 index 0000000..70d4234 --- /dev/null +++ b/tests/unit/Core/RunStateReopenTest.php @@ -0,0 +1,102 @@ +query(). + * + * @var string[] + */ + private array $queries = array(); + + protected function setUp(): void { + parent::setUp(); + $this->queries = array(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'get_var' )->willReturn( 'wp_prautoblogger_runs' ); + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + $this->stub_get_option( array( 'prautoblogger_per_run_cost_ceiling_usd' => '0.75' ) ); + \PRAutoBlogger_Run_State::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Run_State::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** + * reopen() transitions ONLY terminal runs (atomic conditional UPDATE) + * and re-snapshots the ceiling from the current setting. + */ + public function test_reopen_gates_on_terminal_status_and_resnapshots_ceiling(): void { + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + + $this->assertTrue( \PRAutoBlogger_Run_State::reopen( 'run-1' ) ); + + $sql = $this->queries[0]; + $this->assertStringContainsString( "SET status = 'running'", $sql ); + $this->assertStringContainsString( "status IN ('done','failed','halted')", $sql ); + $this->assertStringContainsString( 'ceiling_usd = %f', $sql ); + $this->assertStringContainsString( '0.75', $sql ); // Current setting value. + $this->assertStringContainsString( 'finished_at = NULL', $sql ); + // Guardrail 4: accumulated spend is NEVER reset by a reopen. + $this->assertStringNotContainsString( 'reserved_usd', $sql ); + $this->assertStringNotContainsString( 'settled_usd', $sql ); + $this->assertStringNotContainsString( 'overage_usd', $sql ); + } + + /** + * No row matched (run is pending/running or absent) -> false, so the + * executor aborts instead of replaying against a closed gate. + */ + public function test_reopen_returns_false_when_not_terminal(): void { + $this->wpdb->method( 'query' )->willReturn( 0 ); + + $this->assertFalse( \PRAutoBlogger_Run_State::reopen( 'run-1' ) ); + } + + /** + * Self-healing: missing table / empty run id -> false, no query. + */ + public function test_reopen_noop_paths(): void { + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + + $this->assertFalse( \PRAutoBlogger_Run_State::reopen( '' ) ); + $this->assertCount( 0, $this->queries ); + } +} diff --git a/tests/unit/Core/StageInputStoreTest.php b/tests/unit/Core/StageInputStoreTest.php new file mode 100644 index 0000000..fdd7086 --- /dev/null +++ b/tests/unit/Core/StageInputStoreTest.php @@ -0,0 +1,193 @@ +insert(). + * + * @var array> + */ + private array $inserted = array(); + + /** + * SQL captured from $wpdb->query(). + * + * @var string[] + */ + private array $queries = array(); + + protected function setUp(): void { + parent::setUp(); + $this->inserted = array(); + $this->queries = array(); + $this->wpdb = $this->create_mock_wpdb(); + $GLOBALS['wpdb'] = $this->wpdb; + $this->wpdb->method( 'prepare' )->willReturnCallback( + static function ( $sql, ...$args ) { + if ( 1 === count( $args ) && is_array( $args[0] ) ) { + $args = $args[0]; + } + return $sql . ' /* ' . implode( ',', array_map( 'strval', $args ) ) . ' */'; + } + ); + $this->wpdb->method( 'insert' )->willReturnCallback( + function ( $table, $row ) { + $this->inserted[] = (array) $row; + return 1; + } + ); + $this->wpdb->method( 'query' )->willReturnCallback( + function ( $sql ) { + $this->queries[] = (string) $sql; + return 1; + } + ); + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + } + + protected function tearDown(): void { + \PRAutoBlogger_Stage_Input_Store::flush_cache(); + unset( $GLOBALS['wpdb'] ); + parent::tearDown(); + } + + /** Make the availability probe succeed. */ + private function table_exists(): void { + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + return null; // MAX(version) on an empty scope. + } + ); + } + + /** + * First fork for a scope gets version 1 via INSERT — and the write + * path is INSERT-only: no UPDATE statement may ever be issued. + */ + public function test_save_fork_inserts_version_one_and_never_updates(): void { + $this->table_exists(); + + $version = \PRAutoBlogger_Stage_Input_Store::save_fork( + 'run-1', + 'draft', + 'writer', + 'idea:abc', + '{"model":"m","messages":[]}', + 'rhys' + ); + + $this->assertSame( 1, $version ); + $this->assertCount( 1, $this->inserted ); + $row = $this->inserted[0]; + $this->assertSame( 'human', $row['source'] ); + $this->assertSame( 1, $row['version'] ); + $this->assertSame( 'rhys', $row['author'] ); + // Immutability: the store issued no UPDATE of any kind. + foreach ( $this->queries as $sql ) { + $this->assertStringNotContainsString( 'UPDATE', $sql ); + } + } + + /** + * The next fork for the same scope gets MAX(version)+1 — the original + * version row is never overwritten (CPO guardrail 1). + */ + public function test_save_fork_increments_version_from_existing_max(): void { + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) { + if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + return '3'; // Existing max version. + } + ); + + $version = \PRAutoBlogger_Stage_Input_Store::save_fork( 'run-1', 'draft', 'writer', 'idea:abc', '{}', 'rhys' ); + + $this->assertSame( 4, $version ); + $this->assertSame( 4, $this->inserted[0]['version'] ); + } + + /** + * Seed persists once: a second save for the same item is a no-op + * (idempotent worker resume). + */ + public function test_save_seed_is_insert_once(): void { + $seeded = false; + $this->wpdb->method( 'get_var' )->willReturnCallback( + static function ( $sql ) use ( &$seeded ) { + if ( false !== strpos( (string) $sql, 'SHOW TABLES' ) ) { + return 'wp_prautoblogger_stage_inputs'; + } + if ( false !== strpos( (string) $sql, 'SELECT request_json' ) ) { + return $seeded ? '{"topic":"t"}' : null; + } + return null; + } + ); + + \PRAutoBlogger_Stage_Input_Store::save_seed( 'run-1', 'idea:abc', '{"topic":"t"}' ); + $seeded = true; + \PRAutoBlogger_Stage_Input_Store::save_seed( 'run-1', 'idea:abc', '{"topic":"other"}' ); + + $this->assertCount( 1, $this->inserted ); + $this->assertSame( 'seed', $this->inserted[0]['source'] ); + $this->assertSame( '', $this->inserted[0]['stage'] ); + } + + /** + * Retention prune targets ONLY human fork bodies — the WHERE clause + * pins source='human', so seed rows survive (re-run-from-here keeps + * working for the life of the run). + */ + public function test_prune_targets_human_bodies_only(): void { + $this->table_exists(); + + \PRAutoBlogger_Stage_Input_Store::prune_human_bodies( '2026-05-29 00:00:00' ); + + $this->assertCount( 1, $this->queries ); + $sql = $this->queries[0]; + $this->assertStringContainsString( 'SET request_json = NULL', $sql ); + $this->assertStringContainsString( "source = %s", $sql ); + $this->assertStringContainsString( 'human', $sql ); + $this->assertStringNotContainsString( "seed", $sql ); + } + + /** + * Missing table (half-migrated schema): every method degrades to a + * silent no-op / null — never a query, never a notice. + */ + public function test_missing_table_degrades_to_noop(): void { + $this->wpdb->method( 'get_var' )->willReturn( null ); + + $this->assertNull( \PRAutoBlogger_Stage_Input_Store::save_fork( 'r', 'draft', 'w', 'i', '{}', 'a' ) ); + \PRAutoBlogger_Stage_Input_Store::save_seed( 'r', 'i', '{}' ); + $this->assertNull( \PRAutoBlogger_Stage_Input_Store::get_seed( 'r', 'i' ) ); + $this->assertNull( \PRAutoBlogger_Stage_Input_Store::latest_fork( 'r', 'draft', 'w', 'i' ) ); + $this->assertSame( 0, \PRAutoBlogger_Stage_Input_Store::prune_human_bodies( '2026-05-29 00:00:00' ) ); + $this->assertCount( 0, $this->inserted ); + $this->assertCount( 0, $this->queries ); + } +} diff --git a/tests/unit/Core/StageReplayTest.php b/tests/unit/Core/StageReplayTest.php new file mode 100644 index 0000000..c2d5856 --- /dev/null +++ b/tests/unit/Core/StageReplayTest.php @@ -0,0 +1,157 @@ + options inverse transform (the replay must + * round-trip through Request_Builder::build_body() to the SAME wire + * shape, including the v0.18.1 reasoning headroom) and the decode + * validation that guards the replay path against non-chat payloads. + * + * @package PRAutoBlogger\Tests\Core + */ + +namespace PRAutoBlogger\Tests\Core; + +use PRAutoBlogger\Tests\BaseTestCase; + +class StageReplayTest extends BaseTestCase { + + protected function setUp(): void { + parent::setUp(); + // Builder reads the global reasoning setting; pin it OFF here and + // flip it in the drift test below. + $this->stub_get_option( array( 'prautoblogger_reasoning_enabled' => '0' ) ); + } + + /** + * Round-trip: a body WITHOUT reasoning replayed through build_body() + * reproduces the original wire shape byte-for-byte. + */ + public function test_round_trip_without_reasoning(): void { + $original = array( + 'model' => 'google/gemini-2.5-flash-lite', + 'messages' => array( + array( 'role' => 'system', 'content' => 'sys' ), + array( 'role' => 'user', 'content' => 'edited prompt' ), + ), + 'temperature' => 0.7, + 'max_tokens' => 4000, + ); + + $options = \PRAutoBlogger_Stage_Replay::options_from_body( $original, 'draft' ); + $rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( + $original['messages'], + $original['model'], + $options + ); + + // The explicit reasoning-off pin is the only permitted delta + // (same shape the empty-completion retry sends upstream). + $this->assertSame( array( 'enabled' => false ), $rebuilt['reasoning'] ); + unset( $rebuilt['reasoning'] ); + $this->assertSame( $original, array_merge( array( 'model' => $original['model'], 'messages' => $original['messages'] ), $rebuilt ) ); + $this->assertSame( 4000, $rebuilt['max_tokens'] ); + } + + /** + * Round-trip WITH reasoning: the stored body's max_tokens carries the + * builder's +cap headroom; the inverse subtracts it so the rebuilt + * body is identical (headroom applied once, never compounded). + */ + public function test_round_trip_with_reasoning_headroom(): void { + $original = array( + 'model' => 'deepseek/deepseek-v4-flash', + 'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ), + 'max_tokens' => 6048, // Caller asked 4000; builder added the 2048 cap. + 'reasoning' => array( + 'enabled' => true, + 'max_tokens' => 2048, + ), + ); + + $options = \PRAutoBlogger_Stage_Replay::options_from_body( $original, 'draft' ); + $this->assertSame( 4000, $options['max_tokens'] ); + + $rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( + $original['messages'], + $original['model'], + $options + ); + + $this->assertSame( 6048, $rebuilt['max_tokens'] ); + $this->assertSame( $original['reasoning'], $rebuilt['reasoning'] ); + } + + /** + * Global-setting drift cannot reshape a replay: even with reasoning + * globally ENABLED, a body stored without reasoning replays with the + * explicit off-pin (build_body honours the per-call override). + */ + public function test_global_reasoning_drift_cannot_leak_into_replay(): void { + $this->stub_get_option( + array( + 'prautoblogger_reasoning_enabled' => '1', + 'prautoblogger_reasoning_effort' => 'high', + ) + ); + + $body = array( + 'model' => 'google/gemini-2.5-flash-lite', + 'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ), + 'max_tokens' => 1000, + ); + $options = \PRAutoBlogger_Stage_Replay::options_from_body( $body, 'polish' ); + $rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( $body['messages'], $body['model'], $options ); + + $this->assertSame( array( 'enabled' => false ), $rebuilt['reasoning'] ); + $this->assertSame( 1000, $rebuilt['max_tokens'] ); // No headroom added. + } + + /** + * Caller metadata (stage/prompt_key) rides options for the guard's + * audit rows but never reaches the wire body. + */ + public function test_options_carry_stage_metadata_not_sent_upstream(): void { + $body = array( + 'model' => 'm', + 'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ), + ); + $options = \PRAutoBlogger_Stage_Replay::options_from_body( $body, 'draft' ); + + $this->assertSame( 'draft', $options['stage'] ); + $this->assertSame( 'content.draft', $options['prompt_key'] ); + + $rebuilt = ( new \PRAutoBlogger_OpenRouter_Request_Builder() )->build_body( $body['messages'], $body['model'], $options ); + $this->assertArrayNotHasKey( 'stage', $rebuilt ); + $this->assertArrayNotHasKey( 'prompt_key', $rebuilt ); + } + + /** + * decode_body() accepts only well-formed chat bodies — model string + + * non-empty role/content message list. Everything else returns null + * (the executor aborts cleanly instead of replaying garbage). + */ + public function test_decode_body_validation(): void { + $valid = wp_json_encode( + array( + 'model' => 'm', + 'messages' => array( array( 'role' => 'user', 'content' => 'p' ) ), + ) + ); + $this->assertIsArray( \PRAutoBlogger_Stage_Replay::decode_body( (string) $valid ) ); + + $invalid = array( + 'not json at all', + '"just a string"', + '{"messages":[{"role":"user","content":"p"}]}', // No model. + '{"model":"m"}', // No messages. + '{"model":"m","messages":[]}', // Empty messages. + '{"model":"m","messages":["plain string"]}', // Malformed message. + '{"model":"m","messages":[{"role":"user"}]}', // No content. + '{"model":"m","messages":[{"role":1,"content":"p"}]}', // Non-string role. + ); + foreach ( $invalid as $payload ) { + $this->assertNull( \PRAutoBlogger_Stage_Replay::decode_body( $payload ), "must reject: {$payload}" ); + } + } +} diff --git a/uninstall.php b/uninstall.php index 634d8e6..784ae84 100644 --- a/uninstall.php +++ b/uninstall.php @@ -37,6 +37,8 @@ $tables = array( $prefix . 'run_decisions', $prefix . 'runs', $prefix . 'run_stages', + // Edit + re-run input versions (v0.20.0 / db 1.3.0). + $prefix . 'stage_inputs', ); foreach ( $tables as $table ) { @@ -93,6 +95,8 @@ $hooks = array( 'prautoblogger_generate_queued_article', 'prautoblogger_generate_from_idea', 'prautoblogger_opik_dispatch', + 'prautoblogger_rerun_stage_replay', + 'prautoblogger_rerun_from_stage', ); foreach ( $hooks as $hook ) {