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 += '
+ +
+ ++ 0 ) : ?> + + + + +
+ ++ +
+ + $panel_message ) : ?> +| + | + + + + + | +
|---|
|
+
+
+
+
+
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 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 |